Merge pull request 'harden git HTTP, web headers and UI, speed up browsing, modernize tests' (#10) from fix/review-hardening into main
Reviewed-on: https://git.bnema.dev/bnema/ermite/pulls/10
252 files · +16294 -8189
M .air.toml +1 -1
| @@ -10,7 +10,7 @@ tmp_dir = "tmp" | |||
| 10 | 10 | entrypoint = ["./tmp/ermite", "serve"] | |
| 11 | 11 | # The web assets are embedded (go:embed static), so they need a rebuild too. | |
| 12 | 12 | include_ext = ["go", "templ", "css", "js", "svg", "json", "woff2"] | |
| 13 | - | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "cmd/ermite/testdata"] | |
| 13 | + | exclude_dir = ["gen", "tmp", "scripts", "proto", ".git", ".worktrees", "internal/app/testdata"] | |
| 14 | 14 | # *_templ.go is the output of templ generate: a .templ change triggers the | |
| 15 | 15 | # rebuild, the generated Go file does not trigger a second one. | |
| 16 | 16 | exclude_regex = ["_test\\.go", "_templ\\.go"] | |
M .golangci.yml +1 -0
| @@ -7,6 +7,7 @@ linters: | |||
| 7 | 7 | - ineffassign | |
| 8 | 8 | - misspell | |
| 9 | 9 | - staticcheck | |
| 10 | + | - tparallel | |
| 10 | 11 | - unconvert | |
| 11 | 12 | - unused | |
| 12 | 13 | exclusions: | |
M .hexcheck.yaml +46 -2
| @@ -4,10 +4,10 @@ components: | |||
| 4 | 4 | role: core | |
| 5 | 5 | paths: | |
| 6 | 6 | - internal/domain/** | |
| 7 | - | app: | |
| 7 | + | usecase: | |
| 8 | 8 | role: usecase | |
| 9 | 9 | paths: | |
| 10 | - | - internal/app/** | |
| 10 | + | - internal/usecase/** | |
| 11 | 11 | ports: | |
| 12 | 12 | role: ports | |
| 13 | 13 | paths: | |
| @@ -16,10 +16,23 @@ components: | |||
| 16 | 16 | role: adapter | |
| 17 | 17 | paths: | |
| 18 | 18 | - internal/adapters/** | |
| 19 | + | clientlimit: | |
| 20 | + | # Per-client HTTP rate limiting: infrastructure shared by the HTTP adapters. | |
| 21 | + | role: adapter | |
| 22 | + | paths: | |
| 23 | + | - internal/clientlimit/** | |
| 19 | 24 | entrypoints: | |
| 25 | + | # cmd/ermite only calls app.Run. | |
| 20 | 26 | role: entrypoint | |
| 21 | 27 | paths: | |
| 22 | 28 | - cmd/** | |
| 29 | + | app: | |
| 30 | + | # Composition root and CLI: builds the adapters and wires them to the use | |
| 31 | + | # cases. hexcheck has no dedicated bootstrap role; entrypoint is the | |
| 32 | + | # closest. | |
| 33 | + | role: entrypoint | |
| 34 | + | paths: | |
| 35 | + | - internal/app/** | |
| 23 | 36 | config: | |
| 24 | 37 | role: entrypoint | |
| 25 | 38 | paths: | |
| @@ -30,8 +43,39 @@ components: | |||
| 30 | 43 | - gen/** | |
| 31 | 44 | - '**/mocks/**' | |
| 32 | 45 | - '**/*_templ.go' | |
| 46 | + | readertest: | |
| 47 | + | # Test-only fixtures and conformance suite for ports.RepoReader. | |
| 48 | + | role: ignore | |
| 49 | + | paths: | |
| 50 | + | - internal/readertest/** | |
| 51 | + | # Cross-layer contracts live in ports: adapters and use cases import ports and | |
| 52 | + | # domain, never each other. hexcheck has no rule for an adapter importing a use | |
| 53 | + | # case or internal/app; internal/boundary/boundary_test.go enforces the full | |
| 54 | + | # import matrix. | |
| 55 | + | rules: | |
| 56 | + | no-adapter-imports-in-core: error | |
| 57 | + | no-infra-imports-in-usecase: error | |
| 58 | + | no-framework-types-in-core: error | |
| 59 | + | no-infra-types-in-ports: error | |
| 60 | + | no-adapter-to-adapter-imports: warn | |
| 61 | + | suspicious-business-logic-in-adapter: warn | |
| 62 | + | no-local-fakes-for-ports: warn | |
| 63 | + | missing-generated-mock-for-port: warn | |
| 64 | + | prefer-generated-mocks: warn | |
| 33 | 65 | mocking: | |
| 34 | 66 | generatedMockPaths: | |
| 35 | 67 | - internal/ports/mocks/** | |
| 36 | 68 | generatedMockNamePatterns: | |
| 37 | 69 | - '{{Interface}}' | |
| 70 | + | allow: | |
| 71 | + | # clientlimit is a shared infrastructure library, not a sibling adapter: the | |
| 72 | + | # HTTP adapters that rate-limit clients import it by design. | |
| 73 | + | - rule: no-adapter-to-adapter-imports | |
| 74 | + | path: internal/adapters/githttp/** | |
| 75 | + | reason: rate-limits Git clients with internal/clientlimit | |
| 76 | + | - rule: no-adapter-to-adapter-imports | |
| 77 | + | path: internal/adapters/rpc/** | |
| 78 | + | reason: rate-limits API clients with internal/clientlimit | |
| 79 | + | - rule: no-adapter-to-adapter-imports | |
| 80 | + | path: internal/adapters/web/** | |
| 81 | + | reason: rate-limits login attempts with internal/clientlimit | |
M .mockery.yml +10 -19
| @@ -1,22 +1,13 @@ | |||
| 1 | - | # Mocks of the ports. Regenerate with: go generate ./internal/ports | |
| 1 | + | # Mocks of the ports, generated in internal/ports/mocks. Regenerate with: | |
| 2 | + | # go generate ./internal/ports | |
| 2 | 3 | template: testify | |
| 4 | + | _anchors: | |
| 5 | + | mocks: &mocks | |
| 6 | + | dir: "{{.InterfaceDir}}/mocks" | |
| 7 | + | pkgname: mocks | |
| 8 | + | filename: "{{.InterfaceName}}.go" | |
| 9 | + | structname: "{{.InterfaceName}}" | |
| 10 | + | all: true | |
| 3 | 11 | packages: | |
| 4 | 12 | bnema.dev/ermite/internal/ports: | |
| 5 | - | config: | |
| 6 | - | dir: "{{.InterfaceDir}}/mocks" | |
| 7 | - | pkgname: mocks | |
| 8 | - | filename: "{{.InterfaceName}}.go" | |
| 9 | - | structname: "{{.InterfaceName}}" | |
| 10 | - | interfaces: | |
| 11 | - | RepoMetaStore: | |
| 12 | - | GitRepos: | |
| 13 | - | Clock: | |
| 14 | - | TokenStore: | |
| 15 | - | SessionStore: | |
| 16 | - | GitBackend: | |
| 17 | - | EventBus: | |
| 18 | - | RepoReader: | |
| 19 | - | MergeRequestStore: | |
| 20 | - | MergeGit: | |
| 21 | - | CommitSigner: | |
| 22 | - | RepoFreshener: | |
| 13 | + | config: *mocks | |
M Dockerfile +1 -1
| @@ -24,7 +24,7 @@ COPY . . | |||
| 24 | 24 | ARG VERSION= | |
| 25 | 25 | RUN --mount=type=cache,target=/go/pkg/mod \ | |
| 26 | 26 | --mount=type=cache,target=/root/.cache/go-build \ | |
| 27 | - | go build -trimpath -buildvcs=false -ldflags "-s -w -X main.stamped=${VERSION}" \ | |
| 27 | + | go build -trimpath -buildvcs=false -ldflags "-s -w -X bnema.dev/ermite/internal/app.stamped=${VERSION}" \ | |
| 28 | 28 | -o /out/ermite ./cmd/ermite | |
| 29 | 29 | ||
| 30 | 30 | FROM alpine:3.24 | |
M README.md +12 -0
| @@ -98,6 +98,18 @@ go build ./cmd/ermite | |||
| 98 | 98 | go test ./... | |
| 99 | 99 | ``` | |
| 100 | 100 | ||
| 101 | + | ## Architecture | |
| 102 | + | ||
| 103 | + | `cmd/ermite` (entry point, calls `app.Run`) → `internal/app` (CLI and composition root: builds the adapters and wires them to the use cases) → `internal/usecase` (use cases), `internal/ports` (interfaces and parameter types that cross layers), `internal/domain` (pure values and rules) and `internal/adapters` (Git, KV store, HTTP, API, web, client implementations of the ports). | |
| 104 | + | ||
| 105 | + | - `internal/usecase` imports only `internal/domain` and `internal/ports`. | |
| 106 | + | - Adapters import `internal/domain`, `internal/ports` and the shared `internal/clientlimit`; never a use case, `internal/app` or another adapter. | |
| 107 | + | - The web adapter, like the API and Git over HTTP, calls the use cases through `internal/ports` with the principal of the request (anonymous, or the owner with a valid session); it never imports the generated API code. | |
| 108 | + | - `internal/ports` imports only `internal/domain`; `internal/domain` imports nothing from the module. | |
| 109 | + | - `internal/config` (environment parsing) is used by `internal/app` only. | |
| 110 | + | ||
| 111 | + | `internal/boundary/boundary_test.go` enforces these import rules on production files; `hexcheck` (`.hexcheck.yaml`) checks the layering of types and behaviour. The release version is stamped with `-ldflags "-X bnema.dev/ermite/internal/app.stamped=<version>"`. | |
| 112 | + | ||
| 101 | 113 | ## Develop | |
| 102 | 114 | ||
| 103 | 115 | Tools are pinned in `go.mod`: | |
D cmd/ermite/events_test.go +0 -403
| @@ -1,403 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package main | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "net/http" | |
| 10 | - | "os/exec" | |
| 11 | - | "syscall" | |
| 12 | - | "testing" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "connectrpc.com/connect" | |
| 16 | - | "github.com/stretchr/testify/assert" | |
| 17 | - | "github.com/stretchr/testify/require" | |
| 18 | - | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 19 | - | ||
| 20 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | - | "bnema.dev/ermite/internal/adapters/rpc" | |
| 22 | - | "bnema.dev/ermite/internal/app" | |
| 23 | - | "bnema.dev/ermite/internal/domain" | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // watchWait is how long a test waits for an event that must arrive. Only | |
| 27 | - | // TestAC5PushEventWithinASecond holds the server to a second. | |
| 28 | - | const watchWait = 10 * time.Second | |
| 29 | - | ||
| 30 | - | // AC5: a push shows up in Watch within a second, with the ref updates. | |
| 31 | - | func TestAC5PushEventWithinASecond(t *testing.T) { | |
| 32 | - | h := newHarness(t, harnessOpts{}) | |
| 33 | - | h.seed("pub", domain.VisibilityPublic) | |
| 34 | - | w := h.watch(h.api(""), "", "home") | |
| 35 | - | ||
| 36 | - | wt := h.workTree("main", "a.txt") | |
| 37 | - | start := time.Now() | |
| 38 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 39 | - | ev := w.next(t, time.Second) | |
| 40 | - | assert.Less(t, time.Since(start), 2*time.Second) | |
| 41 | - | ||
| 42 | - | pushed := ev.GetPushed() | |
| 43 | - | require.NotNil(t, pushed, "got %v", ev) | |
| 44 | - | assert.Equal(t, "pub", pushed.GetRepo()) | |
| 45 | - | require.Len(t, pushed.GetRefUpdates(), 1) | |
| 46 | - | up := pushed.GetRefUpdates()[0] | |
| 47 | - | assert.Equal(t, "refs/heads/main", up.GetRef()) | |
| 48 | - | assert.Empty(t, up.GetOldId(), "a created ref has no old id") | |
| 49 | - | assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId()) | |
| 50 | - | assert.NotEmpty(t, ev.GetId()) | |
| 51 | - | assert.NotNil(t, ev.GetTime()) | |
| 52 | - | ||
| 53 | - | // A second push to the same ref carries both ids; a deletion has no new id. | |
| 54 | - | old := up.GetNewId() | |
| 55 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 56 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 57 | - | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 58 | - | assert.Equal(t, old, up2.GetOldId()) | |
| 59 | - | assert.NotEqual(t, old, up2.GetNewId()) | |
| 60 | - | ||
| 61 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |
| 62 | - | w.next(t, watchWait) | |
| 63 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |
| 64 | - | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 65 | - | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |
| 66 | - | assert.NotEmpty(t, del.GetOldId()) | |
| 67 | - | assert.Empty(t, del.GetNewId()) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // AC5: creating, updating and deleting repositories through the API publish | |
| 71 | - | // RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push. | |
| 72 | - | func TestAC5RepoLifecycleEvents(t *testing.T) { | |
| 73 | - | h := newHarness(t, harnessOpts{}) | |
| 74 | - | root := h.api(adminToken) | |
| 75 | - | w := h.watch(root, "") | |
| 76 | - | ||
| 77 | - | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | |
| 78 | - | require.NoError(t, err) | |
| 79 | - | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |
| 80 | - | require.NotNil(t, created) | |
| 81 | - | assert.Equal(t, "life", created.GetName()) | |
| 82 | - | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | |
| 83 | - | assert.Equal(t, "main", created.GetDefaultBranch()) | |
| 84 | - | ||
| 85 | - | _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 86 | - | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 87 | - | })) | |
| 88 | - | require.NoError(t, err) | |
| 89 | - | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() | |
| 90 | - | require.NotNil(t, updated) | |
| 91 | - | assert.Equal(t, "new", updated.GetDescription()) | |
| 92 | - | ||
| 93 | - | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | |
| 94 | - | require.NoError(t, err) | |
| 95 | - | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 96 | - | ||
| 97 | - | // Create on first push publishes RepoCreated once, then the push. | |
| 98 | - | wt := h.workTree("main", "a.txt") | |
| 99 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | |
| 100 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 101 | - | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 102 | - | // Nothing else came in between: the next event is the sentinel. | |
| 103 | - | h.sentinel(t, w, "s-life") | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // AC5: resume from an id replays what came after, then goes live. | |
| 107 | - | func TestAC5Resume(t *testing.T) { | |
| 108 | - | h := newHarness(t, harnessOpts{}) | |
| 109 | - | h.seed("pub", domain.VisibilityPublic) | |
| 110 | - | api := h.api("") | |
| 111 | - | w := h.watch(api, "") | |
| 112 | - | ||
| 113 | - | wt := h.workTree("main", "a.txt") | |
| 114 | - | var ids []string | |
| 115 | - | for i := range 4 { | |
| 116 | - | if i > 0 { | |
| 117 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 118 | - | } | |
| 119 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 120 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 121 | - | } | |
| 122 | - | w.cancel() | |
| 123 | - | ||
| 124 | - | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | |
| 125 | - | r := h.watch(api, ids[1]) | |
| 126 | - | for _, want := range ids[2:] { | |
| 127 | - | ev := r.next(t, watchWait) | |
| 128 | - | assert.Equal(t, want, ev.GetId()) | |
| 129 | - | assert.NotNil(t, ev.GetPushed()) | |
| 130 | - | } | |
| 131 | - | // Nothing was replayed twice: the next event is the live one, below. | |
| 132 | - | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 133 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 134 | - | live := r.next(t, watchWait) | |
| 135 | - | assert.NotNil(t, live.GetPushed()) | |
| 136 | - | assert.NotContains(t, ids, live.GetId()) | |
| 137 | - | ||
| 138 | - | // An id nobody issued is a gap, followed by the oldest buffered event: the | |
| 139 | - | // creation of the repository, which came before the first push. | |
| 140 | - | g := h.watch(api, "not-an-id") | |
| 141 | - | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |
| 142 | - | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 143 | - | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // AC5: a gap when the id fell out of a small ring. | |
| 147 | - | func TestAC5GapAfterOverflow(t *testing.T) { | |
| 148 | - | h := newHarness(t, harnessOpts{eventBuffer: 4}) | |
| 149 | - | h.seed("pub", domain.VisibilityPublic) | |
| 150 | - | api := h.api(adminToken) | |
| 151 | - | w := h.watch(api, "") | |
| 152 | - | wt := h.workTree("main", "a.txt") | |
| 153 | - | ||
| 154 | - | var ids []string | |
| 155 | - | for i := range 8 { | |
| 156 | - | if i > 0 { | |
| 157 | - | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 158 | - | } | |
| 159 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 160 | - | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // ids[2] is out of the ring (the last four are ids[4:]). | |
| 164 | - | r := h.watch(api, ids[2]) | |
| 165 | - | gap := r.next(t, watchWait) | |
| 166 | - | assert.NotNil(t, gap.GetGap()) | |
| 167 | - | assert.Empty(t, gap.GetId()) | |
| 168 | - | for _, want := range ids[4:] { | |
| 169 | - | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |
| 170 | - | } | |
| 171 | - | ||
| 172 | - | // The last buffered id still resumes without a gap. | |
| 173 | - | ok := h.watch(api, ids[4]) | |
| 174 | - | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // AC5: private events never reach anonymous callers, nor tokens that may not | |
| 178 | - | // read the repository; repo topics follow the same rule. | |
| 179 | - | func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |
| 180 | - | h := newHarness(t, harnessOpts{}) | |
| 181 | - | h.seed("pub", domain.VisibilityPublic) | |
| 182 | - | h.seed("priv", domain.VisibilityPrivate) | |
| 183 | - | other := h.token(scopes(domain.ScopeRepoRead), "pub") | |
| 184 | - | reader := h.token(scopes(domain.ScopeRepoRead), "priv") | |
| 185 | - | ||
| 186 | - | anon := h.watch(h.api(""), "") | |
| 187 | - | scoped := h.watch(h.api(other), "") | |
| 188 | - | rd := h.watch(h.api(reader), "") | |
| 189 | - | root := h.watch(h.api(adminToken), "") | |
| 190 | - | ||
| 191 | - | // A private repository's topic is not found for those who cannot read it. | |
| 192 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 193 | - | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv"))) | |
| 194 | - | assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 195 | - | ||
| 196 | - | wt := h.workTree("main", "a.txt") | |
| 197 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 198 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 199 | - | // Metadata events too. | |
| 200 | - | _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 201 | - | Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 202 | - | })) | |
| 203 | - | require.NoError(t, err) | |
| 204 | - | ||
| 205 | - | // The reader of priv sees its push and its update, and pub's push. | |
| 206 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |
| 207 | - | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |
| 208 | - | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |
| 209 | - | // Root sees everything in order. | |
| 210 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 211 | - | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 212 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |
| 213 | - | ||
| 214 | - | // Deleting reaches those who could read the repository before, not others. | |
| 215 | - | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |
| 216 | - | require.NoError(t, err) | |
| 217 | - | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 218 | - | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 219 | - | ||
| 220 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 221 | - | ev := w.next(t, watchWait) | |
| 222 | - | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |
| 223 | - | } | |
| 224 | - | // Nothing about priv came before the sentinel, for either of them. | |
| 225 | - | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |
| 226 | - | require.NoError(t, err) | |
| 227 | - | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 228 | - | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // Resuming does not replay private events to anonymous either: after a | |
| 232 | - | // gap, the creation and the push of pub, the sentinels the watchers above | |
| 233 | - | // saw, and nothing about priv. | |
| 234 | - | r := h.watch(h.api(""), "bogus") | |
| 235 | - | assert.NotNil(t, r.next(t, watchWait).GetGap()) | |
| 236 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 237 | - | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) | |
| 238 | - | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 239 | - | h.sentinel(t, r, "s-resumed") | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // AC5: a repo topic only carries that repository; a revoked token cannot | |
| 243 | - | // start a new watch. | |
| 244 | - | func TestAC5RepoTopic(t *testing.T) { | |
| 245 | - | h := newHarness(t, harnessOpts{}) | |
| 246 | - | h.seed("a", domain.VisibilityPublic) | |
| 247 | - | h.seed("b", domain.VisibilityPublic) | |
| 248 | - | w := h.watch(h.api(""), "", "repo:a") | |
| 249 | - | ||
| 250 | - | wt := h.workTree("main", "x.txt") | |
| 251 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | |
| 252 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 253 | - | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 254 | - | // The push to b was not delivered: the next event is the next push to a. | |
| 255 | - | h.writeFile(wt, "y.txt", []byte("y\n")) | |
| 256 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 257 | - | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | |
| 261 | - | func TestWatchEndsOnCancel(t *testing.T) { | |
| 262 | - | h := newHarness(t, harnessOpts{}) | |
| 263 | - | w := h.watch(h.api(""), "") | |
| 264 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 265 | - | w.cancel() | |
| 266 | - | select { | |
| 267 | - | case <-w.done: | |
| 268 | - | case <-time.After(5 * watchWait): | |
| 269 | - | t.Fatal("the stream did not end") | |
| 270 | - | } | |
| 271 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | // Shutdown ends the streams cleanly: the client sees the stream end without | |
| 275 | - | // an error and the server exits 0 well before the shutdown timeout. | |
| 276 | - | func TestServeShutdownEndsWatchStreams(t *testing.T) { | |
| 277 | - | requireGit(t) | |
| 278 | - | data := t.TempDir() | |
| 279 | - | addr := freeAddr(t) | |
| 280 | - | cmd := exec.Command(binary(t), "serve") | |
| 281 | - | cmd.Env = append(baseEnv(), | |
| 282 | - | "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr, | |
| 283 | - | "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json", | |
| 284 | - | ) | |
| 285 | - | var logs syncBuffer | |
| 286 | - | cmd.Stdout, cmd.Stderr = &logs, &logs | |
| 287 | - | require.NoError(t, cmd.Start()) | |
| 288 | - | exited := make(chan error, 1) | |
| 289 | - | go func() { exited <- cmd.Wait() }() | |
| 290 | - | t.Cleanup(func() { _ = cmd.Process.Kill() }) | |
| 291 | - | ||
| 292 | - | base := "http://" + addr | |
| 293 | - | require.Eventually(t, func() bool { | |
| 294 | - | resp, err := http.Get(base + "/-/health") | |
| 295 | - | if err != nil { | |
| 296 | - | return false | |
| 297 | - | } | |
| 298 | - | _ = resp.Body.Close() | |
| 299 | - | return resp.StatusCode == 200 | |
| 300 | - | }, 15*time.Second, 50*time.Millisecond, "%s", logs.String()) | |
| 301 | - | ||
| 302 | - | c := newClients(base+"/-/api", adminToken) | |
| 303 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 304 | - | defer cancel() | |
| 305 | - | st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 306 | - | require.NoError(t, err) | |
| 307 | - | ended := make(chan error, 1) | |
| 308 | - | go func() { | |
| 309 | - | for st.Receive() { | |
| 310 | - | } | |
| 311 | - | ended <- st.Err() | |
| 312 | - | }() | |
| 313 | - | ||
| 314 | - | // The server version comes from the real binary. | |
| 315 | - | v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 316 | - | require.NoError(t, err) | |
| 317 | - | assert.NotEmpty(t, v.Msg.GetVersion()) | |
| 318 | - | ||
| 319 | - | start := time.Now() | |
| 320 | - | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |
| 321 | - | select { | |
| 322 | - | case err := <-ended: | |
| 323 | - | assert.NoError(t, err, "the stream ends cleanly") | |
| 324 | - | case <-time.After(15 * watchWait): | |
| 325 | - | t.Fatalf("the stream did not end\n%s", logs.String()) | |
| 326 | - | } | |
| 327 | - | select { | |
| 328 | - | case err := <-exited: | |
| 329 | - | require.NoError(t, err, "%s", logs.String()) | |
| 330 | - | case <-time.After(15 * watchWait): | |
| 331 | - | t.Fatalf("the server did not exit\n%s", logs.String()) | |
| 332 | - | } | |
| 333 | - | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |
| 334 | - | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |
| 335 | - | assert.False(t, errors.Is(err, context.Canceled)) | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // A token that is revoked while its Watch is open ends the stream with | |
| 339 | - | // unauthenticated, and the private events that follow never reach it. | |
| 340 | - | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 341 | - | h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond}) | |
| 342 | - | h.seed("priv", domain.VisibilityPrivate) | |
| 343 | - | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{ | |
| 344 | - | Label: "watcher", Scopes: scopes(domain.ScopeRepoRead), | |
| 345 | - | }) | |
| 346 | - | require.NoError(t, err) | |
| 347 | - | w := h.watch(h.api(created.Secret), "") | |
| 348 | - | wt := h.workTree("main", "a.txt") | |
| 349 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 350 | - | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |
| 351 | - | ||
| 352 | - | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | |
| 353 | - | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 354 | - | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 355 | - | ||
| 356 | - | select { | |
| 357 | - | case <-w.done: | |
| 358 | - | case <-time.After(5 * watchWait): | |
| 359 | - | t.Fatal("the stream of a revoked token did not end") | |
| 360 | - | } | |
| 361 | - | assert.Equal(t, "unauthenticated", codeName(w.err)) | |
| 362 | - | for ev := range w.events { | |
| 363 | - | t.Fatalf("an event reached the revoked token: %v", ev) | |
| 364 | - | } | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | // Watch refuses too many topics, and too many streams from one client. | |
| 368 | - | func TestWatchLimits(t *testing.T) { | |
| 369 | - | h := newHarness(t, harnessOpts{}) | |
| 370 | - | api := h.api(adminToken) | |
| 371 | - | ||
| 372 | - | topics := make([]string, app.MaxWatchTopics+1) | |
| 373 | - | for i := range topics { | |
| 374 | - | topics[i] = "home" | |
| 375 | - | } | |
| 376 | - | assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...))) | |
| 377 | - | ||
| 378 | - | for range app.MaxWatchersPerClient { | |
| 379 | - | h.watch(api, "") | |
| 380 | - | } | |
| 381 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |
| 382 | - | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |
| 383 | - | // A stream of another client address is not affected (the harness trusts | |
| 384 | - | // the loopback proxy only when configured: use the in-process caller). | |
| 385 | - | inProc := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |
| 386 | - | ctx, cancel := context.WithCancel(domain.ContextWithPrincipal(h.ctx, domain.Root())) | |
| 387 | - | defer cancel() | |
| 388 | - | st, err := inProc.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 389 | - | require.NoError(t, err) | |
| 390 | - | _ = st.Close() | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | // sentinel proves that nothing reached the stream since the last event it | |
| 394 | - | // returned: it creates a public repository, which every watcher of "home" may | |
| 395 | - | // see, and requires its RepoCreated to be the next event. Events arrive in the | |
| 396 | - | // order they were published. | |
| 397 | - | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |
| 398 | - | t.Helper() | |
| 399 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 400 | - | require.NoError(t, err) | |
| 401 | - | ev := s.next(t, watchWait) | |
| 402 | - | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |
| 403 | - | } | |
M cmd/ermite/main.go +4 -23
| @@ -6,32 +6,13 @@ package main | |||
| 6 | 6 | import ( | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | - | "runtime/debug" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/app" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | func main() { | |
| 13 | - | if err := newRootCmd().Execute(); err != nil { | |
| 14 | + | if err := app.Run(os.Args[1:]); err != nil { | |
| 14 | 15 | fmt.Fprintln(os.Stderr, "ermite:", err) | |
| 15 | - | os.Exit(1) | |
| 16 | + | os.Exit(app.ExitCode(err)) | |
| 16 | 17 | } | |
| 17 | 18 | } | |
| 18 | - | ||
| 19 | - | // stamped is the release version set at link time by the image build | |
| 20 | - | // (-ldflags "-X main.stamped=v0.1.0"). Empty for a plain go build, which then | |
| 21 | - | // reports the module version recorded by the Go toolchain. | |
| 22 | - | var stamped string | |
| 23 | - | ||
| 24 | - | // buildVersion is the version stamped into the binary: the link-time version | |
| 25 | - | // when set, else the module version of the build. | |
| 26 | - | func buildVersion() string { | |
| 27 | - | if stamped != "" { | |
| 28 | - | return stamped | |
| 29 | - | } | |
| 30 | - | info, ok := debug.ReadBuildInfo() | |
| 31 | - | if !ok || info.Main.Version == "" { | |
| 32 | - | return "(devel)" | |
| 33 | - | } | |
| 34 | - | return info.Main.Version | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func version() string { return "ermite " + buildVersion() } | |
D cmd/ermite/testdata/web/empty.html +0 -284
| @@ -1,284 +0,0 @@ | |||
| 1 | - | <!doctype html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="utf-8"> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | - | <meta name="color-scheme" content="light dark"> | |
| 7 | - | <title>quiet · example.test</title> | |
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | - | </script> | |
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 14 | - | </script> | |
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 16 | - | </script> | |
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 18 | - | </script> | |
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | - | </script> | |
| 21 | - | </head> | |
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 23 | - | <a class="skip" href="#main">skip to content</a> | |
| 24 | - | <header class="hdr"> | |
| 25 | - | <div class="hdr-top"> | |
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |
| 28 | - | </span> | |
| 29 | - | <span class="brand-name">example.test</span> | |
| 30 | - | </a> | |
| 31 | - | <nav class="crumbs" aria-label="Path"> | |
| 32 | - | <ol> | |
| 33 | - | <li> | |
| 34 | - | <a href="/quiet" aria-current="page">quiet</a> | |
| 35 | - | </li> | |
| 36 | - | </ol> | |
| 37 | - | </nav> | |
| 38 | - | <div class="hdr-acts"> | |
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |
| 41 | - | </span> | |
| 42 | - | <span class="search-label">Go to…</span> | |
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |
| 44 | - | </button> | |
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 46 | - | </div> | |
| 47 | - | </div> | |
| 48 | - | </header> | |
| 49 | - | <div class="work"> | |
| 50 | - | <main id="main" tabindex="-1"> | |
| 51 | - | <section class="state" aria-labelledby="state-title"> | |
| 52 | - | <p class="eyebrow">empty</p> | |
| 53 | - | <h1 id="state-title">quiet is empty</h1> | |
| 54 | - | <p class="dim">no commits yet. push a branch to create one.</p> | |
| 55 | - | <div class="clone" id="clone-quiet"> | |
| 56 | - | <div class="clone-line"> | |
| 57 | - | <span class="mute" aria-hidden="true">$ </span> | |
| 58 | - | <code>git remote add origin http://example.test/quiet.git</code> | |
| 59 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 60 | - | </div> | |
| 61 | - | <div class="clone-line"> | |
| 62 | - | <span class="mute" aria-hidden="true">$ </span> | |
| 63 | - | <code>git push -u origin main</code> | |
| 64 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 65 | - | </div> | |
| 66 | - | </div> | |
| 67 | - | <p class="state-back"> | |
| 68 | - | <a href="/">home</a> | |
| 69 | - | </p> | |
| 70 | - | </section> | |
| 71 | - | </main> | |
| 72 | - | </div> | |
| 73 | - | <footer class="foot"> | |
| 74 | - | <span>ermite devel</span> | |
| 75 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 76 | - | <span class="foot-keys"> | |
| 77 | - | <kbd>?</kbd> keyboard shortcuts</span> | |
| 78 | - | </footer> | |
| 79 | - | <div class="hint" role="status" aria-live="polite"> | |
| 80 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 81 | - | </span> | |
| 82 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 83 | - | </span> | |
| 84 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 85 | - | </span> | |
| 86 | - | </div> | |
| 87 | - | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 88 | - | <div class="panel-head"> | |
| 89 | - | <h2>Keyboard shortcuts</h2> | |
| 90 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 91 | - | <kbd>esc</kbd> close</button> | |
| 92 | - | </div> | |
| 93 | - | <dl class="keys"> | |
| 94 | - | <div> | |
| 95 | - | <dt> | |
| 96 | - | <kbd>ctrl k</kbd> | |
| 97 | - | <kbd>:</kbd> | |
| 98 | - | </dt> | |
| 99 | - | <dd>go to a section, branch, tag, file or repository</dd> | |
| 100 | - | </div> | |
| 101 | - | <div> | |
| 102 | - | <dt> | |
| 103 | - | <kbd>?</kbd> | |
| 104 | - | </dt> | |
| 105 | - | <dd>this list</dd> | |
| 106 | - | </div> | |
| 107 | - | <div> | |
| 108 | - | <dt> | |
| 109 | - | <kbd>j</kbd> | |
| 110 | - | <kbd>k</kbd> | |
| 111 | - | </dt> | |
| 112 | - | <dd>move the row cursor down / up</dd> | |
| 113 | - | </div> | |
| 114 | - | <div> | |
| 115 | - | <dt> | |
| 116 | - | <kbd>enter</kbd> | |
| 117 | - | </dt> | |
| 118 | - | <dd>open the row under the cursor</dd> | |
| 119 | - | </div> | |
| 120 | - | <div> | |
| 121 | - | <dt> | |
| 122 | - | <kbd>g g</kbd> | |
| 123 | - | <kbd>G</kbd> | |
| 124 | - | </dt> | |
| 125 | - | <dd>first / last row</dd> | |
| 126 | - | </div> | |
| 127 | - | <div> | |
| 128 | - | <dt> | |
| 129 | - | <kbd>/</kbd> | |
| 130 | - | </dt> | |
| 131 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 132 | - | </div> | |
| 133 | - | <div> | |
| 134 | - | <dt> | |
| 135 | - | <kbd>b</kbd> | |
| 136 | - | </dt> | |
| 137 | - | <dd>switch branch or tag</dd> | |
| 138 | - | </div> | |
| 139 | - | <div> | |
| 140 | - | <dt> | |
| 141 | - | <kbd>s</kbd> | |
| 142 | - | <kbd>t</kbd> | |
| 143 | - | <kbd>c</kbd> | |
| 144 | - | <kbd>r</kbd> | |
| 145 | - | <kbd>m</kbd> | |
| 146 | - | </dt> | |
| 147 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 148 | - | </div> | |
| 149 | - | <div> | |
| 150 | - | <dt> | |
| 151 | - | <kbd>g h</kbd> | |
| 152 | - | </dt> | |
| 153 | - | <dd>home</dd> | |
| 154 | - | </div> | |
| 155 | - | <div> | |
| 156 | - | <dt> | |
| 157 | - | <kbd>y</kbd> | |
| 158 | - | </dt> | |
| 159 | - | <dd>copy a permalink pinned to the commit</dd> | |
| 160 | - | </div> | |
| 161 | - | <div> | |
| 162 | - | <dt> | |
| 163 | - | <kbd>d</kbd> | |
| 164 | - | <kbd>u</kbd> | |
| 165 | - | <kbd>x</kbd> | |
| 166 | - | </dt> | |
| 167 | - | <dd>diff: split / unified / collapse or expand all</dd> | |
| 168 | - | </div> | |
| 169 | - | <div> | |
| 170 | - | <dt> | |
| 171 | - | <kbd>n</kbd> | |
| 172 | - | </dt> | |
| 173 | - | <dd>merge request: next note in the diff</dd> | |
| 174 | - | </div> | |
| 175 | - | <div> | |
| 176 | - | <dt> | |
| 177 | - | <kbd>esc</kbd> | |
| 178 | - | </dt> | |
| 179 | - | <dd>close a dialog, leave a field</dd> | |
| 180 | - | </div> | |
| 181 | - | </dl> | |
| 182 | - | </dialog> | |
| 183 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 184 | - | <div class="panel-head"> | |
| 185 | - | <label for="cmd-q" class="sr">go to</label> | |
| 186 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 187 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 188 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 189 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 190 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 191 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 192 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 193 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 194 | - | <kbd>esc</kbd> | |
| 195 | - | </button> | |
| 196 | - | </div> | |
| 197 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 198 | - | <li data-k="go home" data-cursor> | |
| 199 | - | <a href="/" tabindex="-1"> | |
| 200 | - | <span class="lbl">home</span> | |
| 201 | - | <span class="k">go</span> | |
| 202 | - | </a> | |
| 203 | - | </li> | |
| 204 | - | <li data-k="repo demo"> | |
| 205 | - | <a href="/demo" tabindex="-1"> | |
| 206 | - | <span class="lbl">demo</span> | |
| 207 | - | <span class="k">repo</span> | |
| 208 | - | </a> | |
| 209 | - | </li> | |
| 210 | - | </ul> | |
| 211 | - | <p class="panel-foot"> | |
| 212 | - | <span> | |
| 213 | - | <kbd>↑</kbd> | |
| 214 | - | <kbd>↓</kbd> move</span> | |
| 215 | - | <span> | |
| 216 | - | <kbd>enter</kbd> open</span> | |
| 217 | - | <span> | |
| 218 | - | <kbd>esc</kbd> close</span> | |
| 219 | - | </p> | |
| 220 | - | </dialog> | |
| 221 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 222 | - | if (q('dialog[open]').count) return; | |
| 223 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 224 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 225 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 226 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 227 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 228 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 229 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 230 | - | })(1); | |
| 231 | - | k-k from:body -> (async (d) => { | |
| 232 | - | if (q('dialog[open]').count) return; | |
| 233 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 234 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 235 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 236 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 237 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 238 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 239 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 240 | - | })(-1); | |
| 241 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 242 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 243 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 244 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 245 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 246 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 247 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 248 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 249 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 250 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 251 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 252 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 253 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 254 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 255 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 256 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 257 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 258 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 259 | - | k-next from:body -> { | |
| 260 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 261 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 262 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 263 | - | }; | |
| 264 | - | k-split from:body -> { | |
| 265 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 266 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 267 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 268 | - | }; | |
| 269 | - | k-unified from:body -> { | |
| 270 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 271 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 272 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 273 | - | }; | |
| 274 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 275 | - | k-permalink from:body -> { | |
| 276 | - | let b = document.body.dataset; | |
| 277 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 278 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 279 | - | navigator.clipboard?.writeText(location.href); | |
| 280 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 281 | - | }"> | |
| 282 | - | </div> | |
| 283 | - | </body> | |
| 284 | - | </html> | |
D cmd/ermite/testdata/web/home.html +0 -320
| @@ -1,320 +0,0 @@ | |||
| 1 | - | <!doctype html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="utf-8"> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | - | <meta name="color-scheme" content="light dark"> | |
| 7 | - | <title>example.test</title> | |
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | - | </script> | |
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 14 | - | </script> | |
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 16 | - | </script> | |
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 18 | - | </script> | |
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | - | </script> | |
| 21 | - | </head> | |
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 23 | - | <a class="skip" href="#main">skip to content</a> | |
| 24 | - | <header class="hdr"> | |
| 25 | - | <div class="hdr-top"> | |
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |
| 28 | - | </span> | |
| 29 | - | <span class="brand-name">example.test</span> | |
| 30 | - | </a> | |
| 31 | - | <div class="hdr-acts"> | |
| 32 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 33 | - | <span class="ico i-search" aria-hidden="true"> | |
| 34 | - | </span> | |
| 35 | - | <span class="search-label">Go to…</span> | |
| 36 | - | <kbd aria-hidden="true">ctrl k</kbd> | |
| 37 | - | </button> | |
| 38 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 39 | - | </div> | |
| 40 | - | </div> | |
| 41 | - | </header> | |
| 42 | - | <div class="work"> | |
| 43 | - | <main id="main" tabindex="-1"> | |
| 44 | - | <div class="filter"> | |
| 45 | - | <label for="home-filter" class="sr">filter</label> | |
| 46 | - | <input id="home-filter" type="search" data-filter placeholder="Filter repositories…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"> | |
| 47 | - | </div> | |
| 48 | - | <table class="list home" aria-label="Repositories"> | |
| 49 | - | <thead> | |
| 50 | - | <tr> | |
| 51 | - | <th class="cur"> | |
| 52 | - | </th> | |
| 53 | - | <th class="c-name" aria-sort="none"> | |
| 54 | - | <a href="/?sort=name&dir=asc">name </a> | |
| 55 | - | </th> | |
| 56 | - | <th class="c-desc" aria-sort="none"> | |
| 57 | - | <a href="/?sort=desc&dir=asc">description </a> | |
| 58 | - | </th> | |
| 59 | - | <th class="c-when" aria-sort="descending"> | |
| 60 | - | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |
| 61 | - | </a> | |
| 62 | - | </th> | |
| 63 | - | <th class="c-ref" aria-sort="none"> | |
| 64 | - | <a href="/?sort=branch&dir=asc">branch </a> | |
| 65 | - | </th> | |
| 66 | - | </tr> | |
| 67 | - | </thead> | |
| 68 | - | <tbody id="repo-rows" data-rows> | |
| 69 | - | <tr id="repo-demo" data-cursor> | |
| 70 | - | <td class="cur" aria-hidden="true"> | |
| 71 | - | </td> | |
| 72 | - | <td class="c-name"> | |
| 73 | - | <a href="/demo">demo</a> | |
| 74 | - | </td> | |
| 75 | - | <td class="c-desc dim">A demo repository</td> | |
| 76 | - | <td class="c-when nowrap"> | |
| 77 | - | <time datetime="STAMP">AGO</time> | |
| 78 | - | </td> | |
| 79 | - | <td class="c-ref">main</td> | |
| 80 | - | </tr> | |
| 81 | - | <tr id="repo-quiet"> | |
| 82 | - | <td class="cur" aria-hidden="true"> | |
| 83 | - | </td> | |
| 84 | - | <td class="c-name"> | |
| 85 | - | <a href="/quiet">quiet</a> | |
| 86 | - | </td> | |
| 87 | - | <td class="c-desc dim"> | |
| 88 | - | </td> | |
| 89 | - | <td class="c-when nowrap"> | |
| 90 | - | <time>never</time> | |
| 91 | - | </td> | |
| 92 | - | <td class="c-ref">main</td> | |
| 93 | - | </tr> | |
| 94 | - | </tbody> | |
| 95 | - | </table> | |
| 96 | - | </main> | |
| 97 | - | </div> | |
| 98 | - | <footer class="foot"> | |
| 99 | - | <span>ermite devel</span> | |
| 100 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 101 | - | <span class="foot-keys"> | |
| 102 | - | <kbd>?</kbd> keyboard shortcuts</span> | |
| 103 | - | </footer> | |
| 104 | - | <div class="hint" role="status" aria-live="polite"> | |
| 105 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 106 | - | </span> | |
| 107 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 108 | - | </span> | |
| 109 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 110 | - | </span> | |
| 111 | - | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 112 | - | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 113 | - | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 114 | - | gap -> ermiteLive.reload()"> | |
| 115 | - | </span> | |
| 116 | - | </div> | |
| 117 | - | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 118 | - | <div class="panel-head"> | |
| 119 | - | <h2>Keyboard shortcuts</h2> | |
| 120 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 121 | - | <kbd>esc</kbd> close</button> | |
| 122 | - | </div> | |
| 123 | - | <dl class="keys"> | |
| 124 | - | <div> | |
| 125 | - | <dt> | |
| 126 | - | <kbd>ctrl k</kbd> | |
| 127 | - | <kbd>:</kbd> | |
| 128 | - | </dt> | |
| 129 | - | <dd>go to a section, branch, tag, file or repository</dd> | |
| 130 | - | </div> | |
| 131 | - | <div> | |
| 132 | - | <dt> | |
| 133 | - | <kbd>?</kbd> | |
| 134 | - | </dt> | |
| 135 | - | <dd>this list</dd> | |
| 136 | - | </div> | |
| 137 | - | <div> | |
| 138 | - | <dt> | |
| 139 | - | <kbd>j</kbd> | |
| 140 | - | <kbd>k</kbd> | |
| 141 | - | </dt> | |
| 142 | - | <dd>move the row cursor down / up</dd> | |
| 143 | - | </div> | |
| 144 | - | <div> | |
| 145 | - | <dt> | |
| 146 | - | <kbd>enter</kbd> | |
| 147 | - | </dt> | |
| 148 | - | <dd>open the row under the cursor</dd> | |
| 149 | - | </div> | |
| 150 | - | <div> | |
| 151 | - | <dt> | |
| 152 | - | <kbd>g g</kbd> | |
| 153 | - | <kbd>G</kbd> | |
| 154 | - | </dt> | |
| 155 | - | <dd>first / last row</dd> | |
| 156 | - | </div> | |
| 157 | - | <div> | |
| 158 | - | <dt> | |
| 159 | - | <kbd>/</kbd> | |
| 160 | - | </dt> | |
| 161 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 162 | - | </div> | |
| 163 | - | <div> | |
| 164 | - | <dt> | |
| 165 | - | <kbd>b</kbd> | |
| 166 | - | </dt> | |
| 167 | - | <dd>switch branch or tag</dd> | |
| 168 | - | </div> | |
| 169 | - | <div> | |
| 170 | - | <dt> | |
| 171 | - | <kbd>s</kbd> | |
| 172 | - | <kbd>t</kbd> | |
| 173 | - | <kbd>c</kbd> | |
| 174 | - | <kbd>r</kbd> | |
| 175 | - | <kbd>m</kbd> | |
| 176 | - | </dt> | |
| 177 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 178 | - | </div> | |
| 179 | - | <div> | |
| 180 | - | <dt> | |
| 181 | - | <kbd>g h</kbd> | |
| 182 | - | </dt> | |
| 183 | - | <dd>home</dd> | |
| 184 | - | </div> | |
| 185 | - | <div> | |
| 186 | - | <dt> | |
| 187 | - | <kbd>y</kbd> | |
| 188 | - | </dt> | |
| 189 | - | <dd>copy a permalink pinned to the commit</dd> | |
| 190 | - | </div> | |
| 191 | - | <div> | |
| 192 | - | <dt> | |
| 193 | - | <kbd>d</kbd> | |
| 194 | - | <kbd>u</kbd> | |
| 195 | - | <kbd>x</kbd> | |
| 196 | - | </dt> | |
| 197 | - | <dd>diff: split / unified / collapse or expand all</dd> | |
| 198 | - | </div> | |
| 199 | - | <div> | |
| 200 | - | <dt> | |
| 201 | - | <kbd>n</kbd> | |
| 202 | - | </dt> | |
| 203 | - | <dd>merge request: next note in the diff</dd> | |
| 204 | - | </div> | |
| 205 | - | <div> | |
| 206 | - | <dt> | |
| 207 | - | <kbd>esc</kbd> | |
| 208 | - | </dt> | |
| 209 | - | <dd>close a dialog, leave a field</dd> | |
| 210 | - | </div> | |
| 211 | - | </dl> | |
| 212 | - | </dialog> | |
| 213 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 214 | - | <div class="panel-head"> | |
| 215 | - | <label for="cmd-q" class="sr">go to</label> | |
| 216 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 217 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 218 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 219 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 220 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 221 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 222 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 223 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 224 | - | <kbd>esc</kbd> | |
| 225 | - | </button> | |
| 226 | - | </div> | |
| 227 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 228 | - | <li data-k="go home" data-cursor> | |
| 229 | - | <a href="/" tabindex="-1"> | |
| 230 | - | <span class="lbl">home</span> | |
| 231 | - | <span class="k">go</span> | |
| 232 | - | </a> | |
| 233 | - | </li> | |
| 234 | - | <li data-k="repo demo"> | |
| 235 | - | <a href="/demo" tabindex="-1"> | |
| 236 | - | <span class="lbl">demo</span> | |
| 237 | - | <span class="k">repo</span> | |
| 238 | - | </a> | |
| 239 | - | </li> | |
| 240 | - | <li data-k="repo quiet"> | |
| 241 | - | <a href="/quiet" tabindex="-1"> | |
| 242 | - | <span class="lbl">quiet</span> | |
| 243 | - | <span class="k">repo</span> | |
| 244 | - | </a> | |
| 245 | - | </li> | |
| 246 | - | </ul> | |
| 247 | - | <p class="panel-foot"> | |
| 248 | - | <span> | |
| 249 | - | <kbd>↑</kbd> | |
| 250 | - | <kbd>↓</kbd> move</span> | |
| 251 | - | <span> | |
| 252 | - | <kbd>enter</kbd> open</span> | |
| 253 | - | <span> | |
| 254 | - | <kbd>esc</kbd> close</span> | |
| 255 | - | </p> | |
| 256 | - | </dialog> | |
| 257 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 258 | - | if (q('dialog[open]').count) return; | |
| 259 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 260 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 261 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 262 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 263 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 264 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 265 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 266 | - | })(1); | |
| 267 | - | k-k from:body -> (async (d) => { | |
| 268 | - | if (q('dialog[open]').count) return; | |
| 269 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 270 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 271 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 272 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 273 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 274 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 275 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 276 | - | })(-1); | |
| 277 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 278 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 279 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 280 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 281 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 282 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 283 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 284 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 285 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 286 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 287 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 288 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 289 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 290 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 291 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 292 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 293 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 294 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 295 | - | k-next from:body -> { | |
| 296 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 297 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 298 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 299 | - | }; | |
| 300 | - | k-split from:body -> { | |
| 301 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 302 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 303 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 304 | - | }; | |
| 305 | - | k-unified from:body -> { | |
| 306 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 307 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 308 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 309 | - | }; | |
| 310 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 311 | - | k-permalink from:body -> { | |
| 312 | - | let b = document.body.dataset; | |
| 313 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 314 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 315 | - | navigator.clipboard?.writeText(location.href); | |
| 316 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 317 | - | }"> | |
| 318 | - | </div> | |
| 319 | - | </body> | |
| 320 | - | </html> | |
D cmd/ermite/testdata/web/not-found-page.html +0 -267
| @@ -1,267 +0,0 @@ | |||
| 1 | - | <!doctype html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="utf-8"> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | - | <meta name="color-scheme" content="light dark"> | |
| 7 | - | <title>404 · example.test</title> | |
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | - | </script> | |
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 14 | - | </script> | |
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 16 | - | </script> | |
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 18 | - | </script> | |
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | - | </script> | |
| 21 | - | </head> | |
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 23 | - | <a class="skip" href="#main">skip to content</a> | |
| 24 | - | <header class="hdr"> | |
| 25 | - | <div class="hdr-top"> | |
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |
| 28 | - | </span> | |
| 29 | - | <span class="brand-name">example.test</span> | |
| 30 | - | </a> | |
| 31 | - | <nav class="crumbs" aria-label="Path"> | |
| 32 | - | <ol> | |
| 33 | - | <li> | |
| 34 | - | <span aria-current="page">not found</span> | |
| 35 | - | </li> | |
| 36 | - | </ol> | |
| 37 | - | </nav> | |
| 38 | - | <div class="hdr-acts"> | |
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |
| 41 | - | </span> | |
| 42 | - | <span class="search-label">Go to…</span> | |
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |
| 44 | - | </button> | |
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 46 | - | </div> | |
| 47 | - | </div> | |
| 48 | - | </header> | |
| 49 | - | <div class="work"> | |
| 50 | - | <main id="main" tabindex="-1"> | |
| 51 | - | <section class="state" aria-labelledby="state-title"> | |
| 52 | - | <p class="eyebrow">404</p> | |
| 53 | - | <h1 id="state-title">not found</h1> | |
| 54 | - | <p class="dim">no such page, ref or path.</p> | |
| 55 | - | <p class="dim">check the URL, or start from the repo list.</p> | |
| 56 | - | <p class="state-back"> | |
| 57 | - | <a href="/">home</a> | |
| 58 | - | </p> | |
| 59 | - | </section> | |
| 60 | - | </main> | |
| 61 | - | </div> | |
| 62 | - | <footer class="foot"> | |
| 63 | - | <span>ermite devel</span> | |
| 64 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 65 | - | <span class="foot-keys"> | |
| 66 | - | <kbd>?</kbd> keyboard shortcuts</span> | |
| 67 | - | </footer> | |
| 68 | - | <div class="hint" role="status" aria-live="polite"> | |
| 69 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 70 | - | </span> | |
| 71 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 72 | - | </span> | |
| 73 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 74 | - | </span> | |
| 75 | - | </div> | |
| 76 | - | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 77 | - | <div class="panel-head"> | |
| 78 | - | <h2>Keyboard shortcuts</h2> | |
| 79 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 80 | - | <kbd>esc</kbd> close</button> | |
| 81 | - | </div> | |
| 82 | - | <dl class="keys"> | |
| 83 | - | <div> | |
| 84 | - | <dt> | |
| 85 | - | <kbd>ctrl k</kbd> | |
| 86 | - | <kbd>:</kbd> | |
| 87 | - | </dt> | |
| 88 | - | <dd>go to a section, branch, tag, file or repository</dd> | |
| 89 | - | </div> | |
| 90 | - | <div> | |
| 91 | - | <dt> | |
| 92 | - | <kbd>?</kbd> | |
| 93 | - | </dt> | |
| 94 | - | <dd>this list</dd> | |
| 95 | - | </div> | |
| 96 | - | <div> | |
| 97 | - | <dt> | |
| 98 | - | <kbd>j</kbd> | |
| 99 | - | <kbd>k</kbd> | |
| 100 | - | </dt> | |
| 101 | - | <dd>move the row cursor down / up</dd> | |
| 102 | - | </div> | |
| 103 | - | <div> | |
| 104 | - | <dt> | |
| 105 | - | <kbd>enter</kbd> | |
| 106 | - | </dt> | |
| 107 | - | <dd>open the row under the cursor</dd> | |
| 108 | - | </div> | |
| 109 | - | <div> | |
| 110 | - | <dt> | |
| 111 | - | <kbd>g g</kbd> | |
| 112 | - | <kbd>G</kbd> | |
| 113 | - | </dt> | |
| 114 | - | <dd>first / last row</dd> | |
| 115 | - | </div> | |
| 116 | - | <div> | |
| 117 | - | <dt> | |
| 118 | - | <kbd>/</kbd> | |
| 119 | - | </dt> | |
| 120 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 121 | - | </div> | |
| 122 | - | <div> | |
| 123 | - | <dt> | |
| 124 | - | <kbd>b</kbd> | |
| 125 | - | </dt> | |
| 126 | - | <dd>switch branch or tag</dd> | |
| 127 | - | </div> | |
| 128 | - | <div> | |
| 129 | - | <dt> | |
| 130 | - | <kbd>s</kbd> | |
| 131 | - | <kbd>t</kbd> | |
| 132 | - | <kbd>c</kbd> | |
| 133 | - | <kbd>r</kbd> | |
| 134 | - | <kbd>m</kbd> | |
| 135 | - | </dt> | |
| 136 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 137 | - | </div> | |
| 138 | - | <div> | |
| 139 | - | <dt> | |
| 140 | - | <kbd>g h</kbd> | |
| 141 | - | </dt> | |
| 142 | - | <dd>home</dd> | |
| 143 | - | </div> | |
| 144 | - | <div> | |
| 145 | - | <dt> | |
| 146 | - | <kbd>y</kbd> | |
| 147 | - | </dt> | |
| 148 | - | <dd>copy a permalink pinned to the commit</dd> | |
| 149 | - | </div> | |
| 150 | - | <div> | |
| 151 | - | <dt> | |
| 152 | - | <kbd>d</kbd> | |
| 153 | - | <kbd>u</kbd> | |
| 154 | - | <kbd>x</kbd> | |
| 155 | - | </dt> | |
| 156 | - | <dd>diff: split / unified / collapse or expand all</dd> | |
| 157 | - | </div> | |
| 158 | - | <div> | |
| 159 | - | <dt> | |
| 160 | - | <kbd>n</kbd> | |
| 161 | - | </dt> | |
| 162 | - | <dd>merge request: next note in the diff</dd> | |
| 163 | - | </div> | |
| 164 | - | <div> | |
| 165 | - | <dt> | |
| 166 | - | <kbd>esc</kbd> | |
| 167 | - | </dt> | |
| 168 | - | <dd>close a dialog, leave a field</dd> | |
| 169 | - | </div> | |
| 170 | - | </dl> | |
| 171 | - | </dialog> | |
| 172 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 173 | - | <div class="panel-head"> | |
| 174 | - | <label for="cmd-q" class="sr">go to</label> | |
| 175 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 176 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 177 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 178 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 179 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 180 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 181 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 182 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 183 | - | <kbd>esc</kbd> | |
| 184 | - | </button> | |
| 185 | - | </div> | |
| 186 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 187 | - | <li data-k="go home" data-cursor> | |
| 188 | - | <a href="/" tabindex="-1"> | |
| 189 | - | <span class="lbl">home</span> | |
| 190 | - | <span class="k">go</span> | |
| 191 | - | </a> | |
| 192 | - | </li> | |
| 193 | - | </ul> | |
| 194 | - | <p class="panel-foot"> | |
| 195 | - | <span> | |
| 196 | - | <kbd>↑</kbd> | |
| 197 | - | <kbd>↓</kbd> move</span> | |
| 198 | - | <span> | |
| 199 | - | <kbd>enter</kbd> open</span> | |
| 200 | - | <span> | |
| 201 | - | <kbd>esc</kbd> close</span> | |
| 202 | - | </p> | |
| 203 | - | </dialog> | |
| 204 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 205 | - | if (q('dialog[open]').count) return; | |
| 206 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 207 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 208 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 209 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 210 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 211 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 212 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 213 | - | })(1); | |
| 214 | - | k-k from:body -> (async (d) => { | |
| 215 | - | if (q('dialog[open]').count) return; | |
| 216 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 217 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 218 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 219 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 220 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 221 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 222 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 223 | - | })(-1); | |
| 224 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 225 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 226 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 227 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 228 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 229 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 230 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 231 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 232 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 233 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 234 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 235 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 236 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 237 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 238 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 239 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 240 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 241 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 242 | - | k-next from:body -> { | |
| 243 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 244 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 245 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 246 | - | }; | |
| 247 | - | k-split from:body -> { | |
| 248 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 249 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 250 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 251 | - | }; | |
| 252 | - | k-unified from:body -> { | |
| 253 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 254 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 255 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 256 | - | }; | |
| 257 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 258 | - | k-permalink from:body -> { | |
| 259 | - | let b = document.body.dataset; | |
| 260 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 261 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 262 | - | navigator.clipboard?.writeText(location.href); | |
| 263 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 264 | - | }"> | |
| 265 | - | </div> | |
| 266 | - | </body> | |
| 267 | - | </html> | |
D cmd/ermite/testdata/web/not-found.html +0 -267
| @@ -1,267 +0,0 @@ | |||
| 1 | - | <!doctype html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="utf-8"> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | - | <meta name="color-scheme" content="light dark"> | |
| 7 | - | <title>404 · example.test</title> | |
| 8 | - | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | - | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | - | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | - | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | - | </script> | |
| 13 | - | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 14 | - | </script> | |
| 15 | - | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 16 | - | </script> | |
| 17 | - | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 18 | - | </script> | |
| 19 | - | <script src="/-/static/js/files.HASH.js" defer> | |
| 20 | - | </script> | |
| 21 | - | </head> | |
| 22 | - | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 23 | - | <a class="skip" href="#main">skip to content</a> | |
| 24 | - | <header class="hdr"> | |
| 25 | - | <div class="hdr-top"> | |
| 26 | - | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 27 | - | <span class="brand-mark" aria-hidden="true"> | |
| 28 | - | </span> | |
| 29 | - | <span class="brand-name">example.test</span> | |
| 30 | - | </a> | |
| 31 | - | <nav class="crumbs" aria-label="Path"> | |
| 32 | - | <ol> | |
| 33 | - | <li> | |
| 34 | - | <span aria-current="page">not found</span> | |
| 35 | - | </li> | |
| 36 | - | </ol> | |
| 37 | - | </nav> | |
| 38 | - | <div class="hdr-acts"> | |
| 39 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 40 | - | <span class="ico i-search" aria-hidden="true"> | |
| 41 | - | </span> | |
| 42 | - | <span class="search-label">Go to…</span> | |
| 43 | - | <kbd aria-hidden="true">ctrl k</kbd> | |
| 44 | - | </button> | |
| 45 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 46 | - | </div> | |
| 47 | - | </div> | |
| 48 | - | </header> | |
| 49 | - | <div class="work"> | |
| 50 | - | <main id="main" tabindex="-1"> | |
| 51 | - | <section class="state" aria-labelledby="state-title"> | |
| 52 | - | <p class="eyebrow">404</p> | |
| 53 | - | <h1 id="state-title">not found</h1> | |
| 54 | - | <p class="dim">no such page, ref or path.</p> | |
| 55 | - | <p class="dim">check the URL, or start from the repo list.</p> | |
| 56 | - | <p class="state-back"> | |
| 57 | - | <a href="/">home</a> | |
| 58 | - | </p> | |
| 59 | - | </section> | |
| 60 | - | </main> | |
| 61 | - | </div> | |
| 62 | - | <footer class="foot"> | |
| 63 | - | <span>ermite devel</span> | |
| 64 | - | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 65 | - | <span class="foot-keys"> | |
| 66 | - | <kbd>?</kbd> keyboard shortcuts</span> | |
| 67 | - | </footer> | |
| 68 | - | <div class="hint" role="status" aria-live="polite"> | |
| 69 | - | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 70 | - | </span> | |
| 71 | - | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 72 | - | </span> | |
| 73 | - | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 74 | - | </span> | |
| 75 | - | </div> | |
| 76 | - | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 77 | - | <div class="panel-head"> | |
| 78 | - | <h2>Keyboard shortcuts</h2> | |
| 79 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 80 | - | <kbd>esc</kbd> close</button> | |
| 81 | - | </div> | |
| 82 | - | <dl class="keys"> | |
| 83 | - | <div> | |
| 84 | - | <dt> | |
| 85 | - | <kbd>ctrl k</kbd> | |
| 86 | - | <kbd>:</kbd> | |
| 87 | - | </dt> | |
| 88 | - | <dd>go to a section, branch, tag, file or repository</dd> | |
| 89 | - | </div> | |
| 90 | - | <div> | |
| 91 | - | <dt> | |
| 92 | - | <kbd>?</kbd> | |
| 93 | - | </dt> | |
| 94 | - | <dd>this list</dd> | |
| 95 | - | </div> | |
| 96 | - | <div> | |
| 97 | - | <dt> | |
| 98 | - | <kbd>j</kbd> | |
| 99 | - | <kbd>k</kbd> | |
| 100 | - | </dt> | |
| 101 | - | <dd>move the row cursor down / up</dd> | |
| 102 | - | </div> | |
| 103 | - | <div> | |
| 104 | - | <dt> | |
| 105 | - | <kbd>enter</kbd> | |
| 106 | - | </dt> | |
| 107 | - | <dd>open the row under the cursor</dd> | |
| 108 | - | </div> | |
| 109 | - | <div> | |
| 110 | - | <dt> | |
| 111 | - | <kbd>g g</kbd> | |
| 112 | - | <kbd>G</kbd> | |
| 113 | - | </dt> | |
| 114 | - | <dd>first / last row</dd> | |
| 115 | - | </div> | |
| 116 | - | <div> | |
| 117 | - | <dt> | |
| 118 | - | <kbd>/</kbd> | |
| 119 | - | </dt> | |
| 120 | - | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 121 | - | </div> | |
| 122 | - | <div> | |
| 123 | - | <dt> | |
| 124 | - | <kbd>b</kbd> | |
| 125 | - | </dt> | |
| 126 | - | <dd>switch branch or tag</dd> | |
| 127 | - | </div> | |
| 128 | - | <div> | |
| 129 | - | <dt> | |
| 130 | - | <kbd>s</kbd> | |
| 131 | - | <kbd>t</kbd> | |
| 132 | - | <kbd>c</kbd> | |
| 133 | - | <kbd>r</kbd> | |
| 134 | - | <kbd>m</kbd> | |
| 135 | - | </dt> | |
| 136 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 137 | - | </div> | |
| 138 | - | <div> | |
| 139 | - | <dt> | |
| 140 | - | <kbd>g h</kbd> | |
| 141 | - | </dt> | |
| 142 | - | <dd>home</dd> | |
| 143 | - | </div> | |
| 144 | - | <div> | |
| 145 | - | <dt> | |
| 146 | - | <kbd>y</kbd> | |
| 147 | - | </dt> | |
| 148 | - | <dd>copy a permalink pinned to the commit</dd> | |
| 149 | - | </div> | |
| 150 | - | <div> | |
| 151 | - | <dt> | |
| 152 | - | <kbd>d</kbd> | |
| 153 | - | <kbd>u</kbd> | |
| 154 | - | <kbd>x</kbd> | |
| 155 | - | </dt> | |
| 156 | - | <dd>diff: split / unified / collapse or expand all</dd> | |
| 157 | - | </div> | |
| 158 | - | <div> | |
| 159 | - | <dt> | |
| 160 | - | <kbd>n</kbd> | |
| 161 | - | </dt> | |
| 162 | - | <dd>merge request: next note in the diff</dd> | |
| 163 | - | </div> | |
| 164 | - | <div> | |
| 165 | - | <dt> | |
| 166 | - | <kbd>esc</kbd> | |
| 167 | - | </dt> | |
| 168 | - | <dd>close a dialog, leave a field</dd> | |
| 169 | - | </div> | |
| 170 | - | </dl> | |
| 171 | - | </dialog> | |
| 172 | - | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 173 | - | <div class="panel-head"> | |
| 174 | - | <label for="cmd-q" class="sr">go to</label> | |
| 175 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 176 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 177 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 178 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 179 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 180 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 181 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 182 | - | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 183 | - | <kbd>esc</kbd> | |
| 184 | - | </button> | |
| 185 | - | </div> | |
| 186 | - | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 187 | - | <li data-k="go home" data-cursor> | |
| 188 | - | <a href="/" tabindex="-1"> | |
| 189 | - | <span class="lbl">home</span> | |
| 190 | - | <span class="k">go</span> | |
| 191 | - | </a> | |
| 192 | - | </li> | |
| 193 | - | </ul> | |
| 194 | - | <p class="panel-foot"> | |
| 195 | - | <span> | |
| 196 | - | <kbd>↑</kbd> | |
| 197 | - | <kbd>↓</kbd> move</span> | |
| 198 | - | <span> | |
| 199 | - | <kbd>enter</kbd> open</span> | |
| 200 | - | <span> | |
| 201 | - | <kbd>esc</kbd> close</span> | |
| 202 | - | </p> | |
| 203 | - | </dialog> | |
| 204 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 205 | - | if (q('dialog[open]').count) return; | |
| 206 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 207 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 208 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 209 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 210 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 211 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 212 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 213 | - | })(1); | |
| 214 | - | k-k from:body -> (async (d) => { | |
| 215 | - | if (q('dialog[open]').count) return; | |
| 216 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 217 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 218 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 219 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 220 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 221 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 222 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 223 | - | })(-1); | |
| 224 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 225 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 226 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 227 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 228 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 229 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 230 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 231 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 232 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 233 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 234 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 235 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 236 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 237 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 238 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 239 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 240 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 241 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 242 | - | k-next from:body -> { | |
| 243 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 244 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 245 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 246 | - | }; | |
| 247 | - | k-split from:body -> { | |
| 248 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 249 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 250 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 251 | - | }; | |
| 252 | - | k-unified from:body -> { | |
| 253 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 254 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 255 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 256 | - | }; | |
| 257 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 258 | - | k-permalink from:body -> { | |
| 259 | - | let b = document.body.dataset; | |
| 260 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 261 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 262 | - | navigator.clipboard?.writeText(location.href); | |
| 263 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 264 | - | }"> | |
| 265 | - | </div> | |
| 266 | - | </body> | |
| 267 | - | </html> | |
M internal/adapters/ermitegit/cache_test.go +109 -35
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "path/filepath" | |
| 12 | 12 | "sync" | |
| 13 | 13 | "testing" | |
| 14 | + | "testing/synctest" | |
| 14 | 15 | "time" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/zerowrap" | |
| @@ -207,56 +208,129 @@ func TestEncodedCommitIsServedByGit(t *testing.T) { | |||
| 207 | 208 | assert.Equal(t, "café", got.Commits[0].Message) | |
| 208 | 209 | } | |
| 209 | 210 | ||
| 211 | + | // The idle tests run in a synctest bubble: the clock is fake, so IdleTTL is the | |
| 212 | + | // production one and the boundaries are exact. The sweeper ticks every | |
| 213 | + | // IdleTTL/4 from the creation of the adapter and closes what has been idle for | |
| 214 | + | // more than IdleTTL, so a repository last used at t is closed at the first tick | |
| 215 | + | // after t+IdleTTL, which is t+IdleTTL+IdleTTL/4 here (no time passes while the | |
| 216 | + | // fixtures are built: the bubble's clock only moves when everything sleeps). | |
| 217 | + | const idleTTL = ermitegit.DefaultIdleTTL | |
| 218 | + | ||
| 210 | 219 | // A repository that nobody reads for IdleTTL is closed, without a goroutine | |
| 211 | 220 | // per repository. | |
| 212 | 221 | func TestIdleRepositoriesAreClosed(t *testing.T) { | |
| 213 | - | ctx := context.Background() | |
| 214 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond }) | |
| 215 | - | f := commitOf(t, data, "idle") | |
| 216 | - | _, err := a.ListRefs(ctx, f.Name) | |
| 217 | - | require.NoError(t, err) | |
| 218 | - | require.Equal(t, 1, a.OpenRepos()) | |
| 219 | - | require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond) | |
| 222 | + | synctest.Test(t, func(t *testing.T) { | |
| 223 | + | ctx := context.Background() | |
| 224 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) | |
| 225 | + | f := commitOf(t, data, "idle") | |
| 226 | + | _, err := a.ListRefs(ctx, f.Name) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | require.Equal(t, 1, a.OpenRepos()) | |
| 220 | 229 | ||
| 221 | - | // It opens again on the next read. | |
| 222 | - | _, err = a.ListRefs(ctx, f.Name) | |
| 223 | - | require.NoError(t, err) | |
| 224 | - | assert.Equal(t, 1, a.OpenRepos()) | |
| 230 | + | synctest.Sleep(idleTTL) // idle for exactly the TTL: not more | |
| 231 | + | assert.Equal(t, 1, a.OpenRepos(), "not idle for more than the TTL yet") | |
| 232 | + | synctest.Sleep(idleTTL/4 - time.Nanosecond) | |
| 233 | + | assert.Equal(t, 1, a.OpenRepos(), "the sweeper has not ticked yet") | |
| 234 | + | synctest.Sleep(time.Nanosecond) | |
| 235 | + | assert.Equal(t, 0, a.OpenRepos(), "closed by the first sweep after the TTL") | |
| 236 | + | ||
| 237 | + | // It opens again on the next read. | |
| 238 | + | _, err = a.ListRefs(ctx, f.Name) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | assert.Equal(t, 1, a.OpenRepos()) | |
| 241 | + | }) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // A read keeps the repository open: the TTL counts from the last use. | |
| 245 | + | func TestReadsKeepARepositoryOpen(t *testing.T) { | |
| 246 | + | synctest.Test(t, func(t *testing.T) { | |
| 247 | + | ctx := context.Background() | |
| 248 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) | |
| 249 | + | f := commitOf(t, data, "busy") | |
| 250 | + | for range 10 { | |
| 251 | + | _, err := a.ListRefs(ctx, f.Name) | |
| 252 | + | require.NoError(t, err) | |
| 253 | + | synctest.Sleep(idleTTL / 2) | |
| 254 | + | require.Equal(t, 1, a.OpenRepos(), "used within the TTL") | |
| 255 | + | } | |
| 256 | + | synctest.Sleep(idleTTL) | |
| 257 | + | assert.Equal(t, 0, a.OpenRepos(), "closed once nobody reads it") | |
| 258 | + | }) | |
| 225 | 259 | } | |
| 226 | 260 | ||
| 227 | 261 | // A Refresh after a write is not a read: a repository pushed to but never read | |
| 228 | 262 | // still closes after IdleTTL. | |
| 229 | 263 | func TestRefreshIsNotAUse(t *testing.T) { | |
| 230 | - | ctx := context.Background() | |
| 231 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 80 * time.Millisecond }) | |
| 232 | - | f := commitOf(t, data, "pushed") | |
| 233 | - | _, err := a.ListRefs(ctx, f.Name) | |
| 234 | - | require.NoError(t, err) | |
| 235 | - | start := time.Now() | |
| 236 | - | for time.Since(start) < 5*time.Second && a.OpenRepos() == 1 { | |
| 237 | - | a.Refresh(ctx, f.Name) | |
| 238 | - | time.Sleep(20 * time.Millisecond) | |
| 239 | - | } | |
| 240 | - | assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open") | |
| 264 | + | synctest.Test(t, func(t *testing.T) { | |
| 265 | + | ctx := context.Background() | |
| 266 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) | |
| 267 | + | f := commitOf(t, data, "pushed") | |
| 268 | + | _, err := a.ListRefs(ctx, f.Name) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | // A push every 30 seconds, until the sweep that follows the TTL. | |
| 271 | + | for elapsed := time.Duration(0); elapsed < idleTTL+idleTTL/4; elapsed += 30 * time.Second { | |
| 272 | + | require.Equal(t, 1, a.OpenRepos(), "open after %v", elapsed) | |
| 273 | + | a.Refresh(ctx, f.Name) | |
| 274 | + | synctest.Sleep(30 * time.Second) | |
| 275 | + | } | |
| 276 | + | assert.Equal(t, 0, a.OpenRepos(), "refreshes kept an unread repository open") | |
| 277 | + | }) | |
| 241 | 278 | } | |
| 242 | 279 | ||
| 243 | 280 | // The idle close does not cut a blob stream the reader still holds: the library | |
| 244 | 281 | // keeps the stream valid after Close. | |
| 245 | 282 | func TestIdleCloseDoesNotCutAStream(t *testing.T) { | |
| 246 | - | ctx := context.Background() | |
| 247 | - | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = 50 * time.Millisecond }) | |
| 248 | - | f := readertest.NewFixture(t, data, "stream") | |
| 249 | - | content := readertest.RandomBytes(3<<20, 12) | |
| 250 | - | f.WriteBytes("big.bin", content) | |
| 251 | - | f.Commit("big") | |
| 283 | + | synctest.Test(t, func(t *testing.T) { | |
| 284 | + | ctx := context.Background() | |
| 285 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), func(c *ermitegit.Config) { c.IdleTTL = idleTTL }) | |
| 286 | + | f := readertest.NewFixture(t, data, "stream") | |
| 287 | + | content := readertest.RandomBytes(3<<20, 12) | |
| 288 | + | f.WriteBytes("big.bin", content) | |
| 289 | + | f.Commit("big") | |
| 290 | + | ||
| 291 | + | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin")) | |
| 292 | + | require.NoError(t, err) | |
| 293 | + | synctest.Sleep(idleTTL + idleTTL/4) | |
| 294 | + | require.Equal(t, 0, a.OpenRepos()) | |
| 295 | + | got, err := io.ReadAll(rc) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | require.NoError(t, rc.Close()) | |
| 298 | + | assert.True(t, bytes.Equal(content, got)) | |
| 299 | + | }) | |
| 300 | + | } | |
| 252 | 301 | ||
| 253 | - | _, rc, err := a.OpenBlob(ctx, f.Name, domain.Revision{}, readertest.TreePath(t, "big.bin")) | |
| 254 | - | require.NoError(t, err) | |
| 255 | - | require.Eventually(t, func() bool { return a.OpenRepos() == 0 }, 5*time.Second, 10*time.Millisecond) | |
| 256 | - | got, err := io.ReadAll(rc) | |
| 257 | - | require.NoError(t, err) | |
| 258 | - | require.NoError(t, rc.Close()) | |
| 259 | - | assert.True(t, bytes.Equal(content, got)) | |
| 302 | + | // A repository ermite-git could not read is served by git for UnsupportedRetry, | |
| 303 | + | // then opened again: a repack, or the removal of a replace ref, can make it | |
| 304 | + | // readable. The commit-graph of the history fixture is mapped only while the | |
| 305 | + | // library has the repository open. | |
| 306 | + | func TestUnsupportedRepositoryIsTriedAgainAfterTheRetryDelay(t *testing.T) { | |
| 307 | + | synctest.Test(t, func(t *testing.T) { | |
| 308 | + | ctx := context.Background() | |
| 309 | + | a, _, data := newAdapter(t, ermitegit.AllRoutes(ermitegit.ErmiteGit), nil) | |
| 310 | + | h := readertest.NewHistory(t, data) | |
| 311 | + | h.Maintain() | |
| 312 | + | alternates := filepath.Join(h.Bare, "objects", "info", "alternates") | |
| 313 | + | require.NoError(t, os.WriteFile(alternates, []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644)) | |
| 314 | + | read := func() { | |
| 315 | + | t.Helper() | |
| 316 | + | // ListRefs: git's answer is a one-shot command, where ListCommits | |
| 317 | + | // would start a long-lived cat-file process, whose pipes keep the | |
| 318 | + | // bubble from ever being idle. | |
| 319 | + | _, err := a.ListRefs(ctx, h.Name) | |
| 320 | + | require.NoError(t, err) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | read() | |
| 324 | + | require.False(t, mapped(t, h.Bare), "unsupported: served by git") | |
| 325 | + | require.NoError(t, os.Remove(alternates)) // readable now | |
| 326 | + | ||
| 327 | + | synctest.Sleep(ermitegit.UnsupportedRetry) | |
| 328 | + | read() | |
| 329 | + | assert.False(t, mapped(t, h.Bare), "still served by git: the delay is not over") | |
| 330 | + | synctest.Sleep(time.Nanosecond) | |
| 331 | + | read() | |
| 332 | + | assert.True(t, mapped(t, h.Bare), "tried again once the delay is over") | |
| 333 | + | }) | |
| 260 | 334 | } | |
| 261 | 335 | ||
| 262 | 336 | // mapped reports whether the process maps a file under dir (Linux). | |
M internal/adapters/ermitegit/ermitegit.go +20 -8
| @@ -87,9 +87,13 @@ type Routes struct { | |||
| 87 | 87 | ResolveRefPath Backend | |
| 88 | 88 | // GetTree is a directory listing without last commits. | |
| 89 | 89 | GetTree Backend | |
| 90 | - | // GetTreeLastCommits is a listing with the last commit of each entry. | |
| 90 | + | // GetTreeLastCommits is the listing of the root with the last commit of | |
| 91 | + | // each entry. | |
| 91 | 92 | GetTreeLastCommits Backend | |
| 92 | - | OpenBlob Backend | |
| 93 | + | // GetTreeSubdirLastCommits is the same for a subdirectory, which costs a | |
| 94 | + | // much longer history walk (195 ms with git on lazygit). | |
| 95 | + | GetTreeSubdirLastCommits Backend | |
| 96 | + | OpenBlob Backend | |
| 93 | 97 | // ListCommits is a page of history of a revision. | |
| 94 | 98 | ListCommits Backend | |
| 95 | 99 | // ListCommitsPath is a page of the history of a file or a directory. | |
| @@ -98,10 +102,22 @@ type Routes struct { | |||
| 98 | 102 | ListPaths Backend | |
| 99 | 103 | } | |
| 100 | 104 | ||
| 105 | + | // tree picks the route of a directory listing. | |
| 106 | + | func (r Routes) tree(path domain.TreePath, lastCommits bool) Backend { | |
| 107 | + | switch { | |
| 108 | + | case !lastCommits: | |
| 109 | + | return r.GetTree | |
| 110 | + | case path.IsRoot(): | |
| 111 | + | return r.GetTreeLastCommits | |
| 112 | + | default: | |
| 113 | + | return r.GetTreeSubdirLastCommits | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 101 | 117 | // AllRoutes returns routes that send every method to b. The tests and the | |
| 102 | 118 | // benchmarks use it to exercise a backend whatever the production routes are. | |
| 103 | 119 | func AllRoutes(b Backend) Routes { | |
| 104 | - | return Routes{b, b, b, b, b, b, b, b, b, b} | |
| 120 | + | return Routes{b, b, b, b, b, b, b, b, b, b, b} | |
| 105 | 121 | } | |
| 106 | 122 | ||
| 107 | 123 | // Config configures an Adapter. | |
| @@ -335,11 +351,7 @@ func (a *Adapter) ListPaths(ctx context.Context, repo domain.RepoName, rev domai | |||
| 335 | 351 | // GetTree lists a directory. | |
| 336 | 352 | func (a *Adapter) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |
| 337 | 353 | cli := func() (domain.Tree, error) { return a.cli.GetTree(ctx, repo, rev, path, includeLastCommit) } | |
| 338 | - | route := a.routes.GetTree | |
| 339 | - | if includeLastCommit { | |
| 340 | - | route = a.routes.GetTreeLastCommits | |
| 341 | - | } | |
| 342 | - | if route == CLI { | |
| 354 | + | if a.routes.tree(path, includeLastCommit) == CLI { | |
| 343 | 355 | return cli() | |
| 344 | 356 | } | |
| 345 | 357 | return serve(ctx, a, repo, func(r *git.Repo) (domain.Tree, error) { | |
A internal/adapters/ermitegit/export_test.go +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ermitegit | |
| 4 | + | ||
| 5 | + | // UnsupportedRetry exposes unsupportedRetry to the tests. | |
| 6 | + | const UnsupportedRetry = unsupportedRetry | |
M internal/adapters/ermitegit/routes.go +58 -46
| @@ -4,16 +4,20 @@ package ermitegit | |||
| 4 | 4 | ||
| 5 | 5 | // DefaultRoutes returns the routes of production. | |
| 6 | 6 | // | |
| 7 | - | // The rule: a method is routed to ermite-git only when it passes the shared | |
| 7 | + | // The rule: a method is routed to ermite-git when it passes the shared | |
| 8 | 8 | // conformance table (TestConformance, TestCompareWithCLI) and its benchmark | |
| 9 | - | // beats the CLI adapter in time, in bytes AND in allocations per call, warm | |
| 10 | - | // AND cold, on the generated fixture AND on lazygit (BenchmarkRepoReader, | |
| 11 | - | // ERMITE_GIT_BENCH_REPO). Where a method has several shapes, every shape must | |
| 12 | - | // win: the constants GetTree, GetTreeLastCommits, ResolveRevisionAbbrev and | |
| 13 | - | // ListCommitsPath cover directories of any size and files of any history. A | |
| 14 | - | // method that does not win stays on the CLI. GetCommit is not routable: the | |
| 15 | - | // diff stays on git. ListPaths has one shape: the fixture lists 131 files and | |
| 16 | - | // lazygit 2,180. | |
| 9 | + | // beats the CLI adapter WARM in time AND in bytes per call, on the generated | |
| 10 | + | // fixture AND on lazygit (BenchmarkRepoReader, ERMITE_GIT_BENCH_REPO), and its | |
| 11 | + | // COLD run does not lose in time by more than the open itself (about 65 µs, | |
| 12 | + | // paid once per open, see below). Allocation counts are reported but do not | |
| 13 | + | // decide: the repository cache (cache.go) keeps up to 64 repositories open for | |
| 14 | + | // ten minutes, so nearly every request is warm, and the CLI's own cost (a | |
| 15 | + | // process per call, absent from the Go counters) is not in its numbers. Where | |
| 16 | + | // a method has several shapes that behave differently, each has its own | |
| 17 | + | // constant: GetTree, GetTreeLastCommits (root), GetTreeSubdirLastCommits, | |
| 18 | + | // ResolveRevisionAbbrev and ListCommitsPath. A method that does not win stays | |
| 19 | + | // on the CLI. GetCommit is not routable: the diff stays on git. ListPaths has | |
| 20 | + | // one shape: the fixture lists 131 files and lazygit 2,180. | |
| 17 | 21 | // | |
| 18 | 22 | // Warm is a repository that is open; cold closes it before every call, the | |
| 19 | 23 | // price of the first read after the idle TTL, an eviction or a restart (a | |
| @@ -97,45 +101,53 @@ package ermitegit | |||
| 97 | 101 | // ListCommits/dirpath lazygit warm 6.7ms 127.8K 403 2.0ms 50.2K 181 win | |
| 98 | 102 | // ListCommits/dirpath lazygit cold 6.7ms 127.8K 403 3.3ms 887.1K 2019 loses ba | |
| 99 | 103 | // | |
| 100 | - | // What decides the routes, with ermite-git 5228465 (zlib reader with a reused | |
| 101 | - | // adler32, header-size cache, Refresh and LookupRefID without allocations): | |
| 104 | + | // The "verdict" column is the strict reading, every line of the table won; | |
| 105 | + | // the routes follow the rule above. What decides them, with ermite-git 5228465 | |
| 106 | + | // (zlib reader with a reused adler32, header-size cache, Refresh and | |
| 107 | + | // LookupRefID without allocations): | |
| 102 | 108 | // | |
| 103 | - | // - The warm runs all win but GetTree of a large directory on lazygit (time | |
| 104 | - | // is won since the zlib change, bytes are not) and OpenBlob, whose | |
| 105 | - | // allocations come from the inflate of a small blob and whose time is | |
| 106 | - | // lost for a 10 MiB blob (inflate speed: 52 ms against 21 ms). | |
| 107 | - | // - The cold runs lose in allocations for every method that does little | |
| 108 | - | // work after the open (ListRefs, GetTree, ResolveRefPath: 150 to 200 | |
| 109 | - | // against 60 to 140), and in time too for the ones that cost a few | |
| 110 | - | // microseconds warm (ResolveRevision: 65 µs against 9 to 63 µs). | |
| 111 | - | // - Only the history of a revision (ListCommits page 1 and deep) and the | |
| 112 | - | // recursive path list (ListPaths) win every line, so they are the only | |
| 113 | - | // methods routed. ListPaths wins even cold because one walk replaces a | |
| 114 | - | // process and the parsing of its output (the bytes of the paths are kept in | |
| 115 | - | // 32 KiB blocks, which is what brought the lazygit bytes under the CLI's: | |
| 116 | - | // a single growing buffer lost them, 699K against 433K). GetTree dir+last | |
| 117 | - | // also wins every line, but GetTreeLastCommits covers GetTree root+last | |
| 118 | - | // too, which loses cold allocations on lazygit (4204 against 3073): it is | |
| 119 | - | // the nearest miss, and the first to flip once opening a repository | |
| 120 | - | // allocates less. | |
| 121 | - | // - OpenBlob by size: below 1 MiB the small blob loses cold and, on lazygit, | |
| 122 | - | // warm bytes and allocations; above, it loses time. No size makes it win, | |
| 123 | - | // so there is no size-based route. | |
| 124 | - | // | |
| 125 | - | // Once opening a repository allocates less, re-run the benchmark and flip the | |
| 126 | - | // constants it wins: ListRefs, ResolveRefPath and ResolveRevision win warm by 4 | |
| 127 | - | // to 60 times in time. | |
| 109 | + | // - ListRefs (every repository page, through loadRepo): 905 µs against 24 µs | |
| 110 | + | // warm, 120 KiB against 19 KiB (fixture), 960 against 16 µs and 108 KiB | |
| 111 | + | // against 2 KiB (lazygit). Cold it is still 4 to 10 times faster | |
| 112 | + | // (93 µs); only its allocation count loses. Routed. | |
| 113 | + | // - ResolveRevision (name, full id, abbreviation) and ResolveRefPath: 4 to 8 | |
| 114 | + | // times faster warm, with fewer bytes. Cold they lose 45 µs of time to the | |
| 115 | + | // open, but the ListRefs of the same page has opened the repository | |
| 116 | + | // already, so the open is paid by a call that wins it back. Routed. | |
| 117 | + | // - GetTree with last commits: root listing 4.1 ms against 68 µs on the | |
| 118 | + | // fixture, 20.5 ms against 6.2 ms on lazygit (cold 8.7 ms; its 4,206 | |
| 119 | + | // allocations against 3,082 are the only line lost); a subdirectory of | |
| 120 | + | // lazygit 195-199 ms against 8-9 ms warm, 15-17 ms cold, 20 MiB against | |
| 121 | + | // 1.5 MiB. They are two constants so that either can be flipped back on | |
| 122 | + | // its own. Both routed. | |
| 123 | + | // - GetTree without last commits wins warm in time everywhere (13 µs against | |
| 124 | + | // 0.7 ms at the root), but a large directory of lazygit loses warm bytes | |
| 125 | + | // (325 KiB against 275 KiB) and, cold, 4.0 ms against 1.4 ms in time. It | |
| 126 | + | // stays on the CLI. | |
| 127 | + | // - ListCommits of a revision wins every line and stays routed. | |
| 128 | + | // ListCommitsPath wins warm and cold in time (lazygit file: 12.9 ms against | |
| 129 | + | // 6.0 ms warm), only its cold bytes lose (2.4 MiB): it is a candidate, not | |
| 130 | + | // flipped here, to be decided with its own conformance review. | |
| 131 | + | // - OpenBlob by size: below 1 MiB the small blob loses warm bytes and | |
| 132 | + | // allocations on lazygit; above, it loses time (inflate speed: 52 ms | |
| 133 | + | // against 21 ms). No size makes it win, so there is no size-based route. | |
| 134 | + | // - ListPaths (the recursive path list of the finder) wins every line, even | |
| 135 | + | // cold, because one walk replaces a process and the parsing of its output | |
| 136 | + | // (the bytes of the paths are kept in 32 KiB blocks, which is what brought | |
| 137 | + | // the lazygit bytes under the CLI's: a single growing buffer lost them, | |
| 138 | + | // 699K against 433K). Routed. | |
| 128 | 139 | func DefaultRoutes() Routes { | |
| 129 | 140 | return Routes{ | |
| 130 | - | ListRefs: CLI, | |
| 131 | - | ResolveRevision: CLI, | |
| 132 | - | ResolveRevisionAbbrev: CLI, | |
| 133 | - | ResolveRefPath: CLI, | |
| 134 | - | GetTree: CLI, | |
| 135 | - | GetTreeLastCommits: CLI, | |
| 136 | - | OpenBlob: CLI, | |
| 137 | - | ListCommits: ErmiteGit, | |
| 138 | - | ListCommitsPath: CLI, | |
| 139 | - | ListPaths: ErmiteGit, | |
| 141 | + | ListRefs: ErmiteGit, | |
| 142 | + | ResolveRevision: ErmiteGit, | |
| 143 | + | ResolveRevisionAbbrev: ErmiteGit, | |
| 144 | + | ResolveRefPath: ErmiteGit, | |
| 145 | + | GetTree: CLI, | |
| 146 | + | GetTreeLastCommits: ErmiteGit, | |
| 147 | + | GetTreeSubdirLastCommits: ErmiteGit, | |
| 148 | + | OpenBlob: CLI, | |
| 149 | + | ListCommits: ErmiteGit, | |
| 150 | + | ListCommitsPath: CLI, | |
| 151 | + | ListPaths: ErmiteGit, | |
| 140 | 152 | } | |
| 141 | 153 | } | |
M internal/adapters/eventbus/bus_test.go +105 -85
| @@ -4,9 +4,10 @@ package eventbus_test | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "runtime" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "testing" | |
| 9 | - | "time" | |
| 10 | + | "testing/synctest" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/stretchr/testify/assert" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -19,121 +20,140 @@ func ev(ref string) domain.Event { | |||
| 19 | 20 | return domain.PushEvent{Updates: []domain.RefUpdate{{Ref: ref}}} | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 23 | + | // recv takes the event a subscriber already has. Publish delivers into the | |
| 24 | + | // buffer before it returns, so there is nothing to wait for. | |
| 22 | 25 | func recv(t *testing.T, ch <-chan domain.Event) domain.Event { | |
| 23 | 26 | t.Helper() | |
| 27 | + | synctest.Wait() | |
| 24 | 28 | select { | |
| 25 | 29 | case e, ok := <-ch: | |
| 26 | 30 | require.True(t, ok, "channel closed") | |
| 27 | 31 | return e | |
| 28 | - | case <-time.After(2 * time.Second): | |
| 32 | + | default: | |
| 29 | 33 | t.Fatal("no event") | |
| 30 | 34 | return nil | |
| 31 | 35 | } | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 34 | 38 | func TestFanOut(t *testing.T) { | |
| 35 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 36 | - | defer cancel() | |
| 37 | - | b := eventbus.New() | |
| 38 | - | a, c := b.Subscribe(ctx), b.Subscribe(ctx) | |
| 39 | + | t.Parallel() | |
| 40 | + | synctest.Test(t, func(t *testing.T) { | |
| 41 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 42 | + | defer cancel() | |
| 43 | + | b := eventbus.New() | |
| 44 | + | a, c := b.Subscribe(ctx), b.Subscribe(ctx) | |
| 39 | 45 | ||
| 40 | - | b.Publish(ctx, ev("1")) | |
| 41 | - | b.Publish(ctx, domain.RepoCreated{}) | |
| 42 | - | assert.Equal(t, ev("1"), recv(t, a)) | |
| 43 | - | assert.Equal(t, ev("1"), recv(t, c)) | |
| 44 | - | assert.Equal(t, "repo.created", recv(t, a).EventKind()) | |
| 45 | - | assert.Equal(t, "repo.created", recv(t, c).EventKind()) | |
| 46 | + | b.Publish(ctx, ev("1")) | |
| 47 | + | b.Publish(ctx, domain.RepoCreated{}) | |
| 48 | + | assert.Equal(t, ev("1"), recv(t, a)) | |
| 49 | + | assert.Equal(t, ev("1"), recv(t, c)) | |
| 50 | + | assert.Equal(t, "repo.created", recv(t, a).EventKind()) | |
| 51 | + | assert.Equal(t, "repo.created", recv(t, c).EventKind()) | |
| 52 | + | }) | |
| 46 | 53 | } | |
| 47 | 54 | ||
| 48 | 55 | func TestOnlyEventsFromSubscription(t *testing.T) { | |
| 49 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 50 | - | defer cancel() | |
| 51 | - | b := eventbus.New() | |
| 52 | - | b.Publish(ctx, ev("before")) | |
| 53 | - | ch := b.Subscribe(ctx) | |
| 54 | - | b.Publish(ctx, ev("after")) | |
| 55 | - | assert.Equal(t, ev("after"), recv(t, ch)) | |
| 56 | + | t.Parallel() | |
| 57 | + | synctest.Test(t, func(t *testing.T) { | |
| 58 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 59 | + | defer cancel() | |
| 60 | + | b := eventbus.New() | |
| 61 | + | b.Publish(ctx, ev("before")) | |
| 62 | + | ch := b.Subscribe(ctx) | |
| 63 | + | b.Publish(ctx, ev("after")) | |
| 64 | + | assert.Equal(t, ev("after"), recv(t, ch)) | |
| 65 | + | synctest.Wait() | |
| 66 | + | assert.Empty(t, ch, "nothing else is buffered") | |
| 67 | + | }) | |
| 56 | 68 | } | |
| 57 | 69 | ||
| 58 | 70 | func TestSlowSubscriberGetsGapAndDoesNotBlock(t *testing.T) { | |
| 59 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 60 | - | defer cancel() | |
| 61 | - | b := eventbus.NewWithBuffer(4) | |
| 62 | - | slow, fast := b.Subscribe(ctx), b.Subscribe(ctx) | |
| 71 | + | t.Parallel() | |
| 72 | + | synctest.Test(t, func(t *testing.T) { | |
| 73 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 74 | + | defer cancel() | |
| 75 | + | b := eventbus.NewWithBuffer(4) | |
| 76 | + | slow, fast := b.Subscribe(ctx), b.Subscribe(ctx) | |
| 63 | 77 | ||
| 64 | - | done := make(chan struct{}) | |
| 65 | - | go func() { | |
| 66 | - | defer close(done) | |
| 78 | + | // Publish runs in this goroutine: one that blocked on the slow | |
| 79 | + | // subscriber would deadlock the bubble and fail the test. | |
| 67 | 80 | for i := range 20 { | |
| 68 | 81 | b.Publish(ctx, ev(string(rune('a'+i)))) | |
| 69 | 82 | // The fast subscriber keeps up. | |
| 70 | - | recv(t, fast) | |
| 83 | + | assert.Equal(t, ev(string(rune('a'+i))), recv(t, fast)) | |
| 71 | 84 | } | |
| 72 | - | }() | |
| 73 | - | select { | |
| 74 | - | case <-done: | |
| 75 | - | case <-time.After(5 * time.Second): | |
| 76 | - | t.Fatal("Publish blocked on a slow subscriber") | |
| 77 | - | } | |
| 78 | 85 | ||
| 79 | - | // The slow one lost events: it sees a gap marker, then only recent events. | |
| 80 | - | var got []domain.Event | |
| 81 | - | for len(slow) > 0 { | |
| 82 | - | got = append(got, <-slow) | |
| 83 | - | } | |
| 84 | - | require.NotEmpty(t, got) | |
| 85 | - | var gapAt = -1 | |
| 86 | - | for i, e := range got { | |
| 87 | - | if _, ok := e.(domain.EventGap); ok { | |
| 88 | - | gapAt = i | |
| 86 | + | // The slow one lost events: it sees a gap marker, then only recent events. | |
| 87 | + | var got []domain.Event | |
| 88 | + | for len(slow) > 0 { | |
| 89 | + | got = append(got, <-slow) | |
| 89 | 90 | } | |
| 90 | - | } | |
| 91 | - | assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered") | |
| 92 | - | assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered") | |
| 93 | - | assert.LessOrEqual(t, len(got), 4) | |
| 91 | + | require.NotEmpty(t, got) | |
| 92 | + | var gapAt = -1 | |
| 93 | + | for i, e := range got { | |
| 94 | + | if _, ok := e.(domain.EventGap); ok { | |
| 95 | + | gapAt = i | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | assert.GreaterOrEqual(t, gapAt, 0, "a gap marker is delivered") | |
| 99 | + | assert.Equal(t, ev("t"), got[len(got)-1], "the latest event is delivered") | |
| 100 | + | assert.LessOrEqual(t, len(got), 4) | |
| 101 | + | }) | |
| 94 | 102 | } | |
| 95 | 103 | ||
| 96 | 104 | func TestUnsubscribeOnContext(t *testing.T) { | |
| 97 | - | b := eventbus.New() | |
| 98 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 99 | - | ch := b.Subscribe(ctx) | |
| 100 | - | require.Equal(t, 1, b.Subscribers()) | |
| 101 | - | cancel() | |
| 102 | - | select { | |
| 103 | - | case _, ok := <-ch: | |
| 104 | - | assert.False(t, ok) | |
| 105 | - | case <-time.After(2 * time.Second): | |
| 106 | - | t.Fatal("channel not closed") | |
| 107 | - | } | |
| 108 | - | assert.Equal(t, 0, b.Subscribers()) | |
| 109 | - | b.Publish(context.Background(), ev("x")) // no panic | |
| 105 | + | t.Parallel() | |
| 106 | + | synctest.Test(t, func(t *testing.T) { | |
| 107 | + | b := eventbus.New() | |
| 108 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 109 | + | ch := b.Subscribe(ctx) | |
| 110 | + | require.Equal(t, 1, b.Subscribers()) | |
| 111 | + | synctest.Wait() | |
| 112 | + | require.Equal(t, 1, b.Subscribers(), "still subscribed while ctx lives") | |
| 113 | + | cancel() | |
| 114 | + | synctest.Wait() // the AfterFunc of the context has run | |
| 115 | + | select { | |
| 116 | + | case _, ok := <-ch: | |
| 117 | + | assert.False(t, ok) | |
| 118 | + | default: | |
| 119 | + | t.Fatal("channel not closed") | |
| 120 | + | } | |
| 121 | + | assert.Equal(t, 0, b.Subscribers()) | |
| 122 | + | b.Publish(context.Background(), ev("x")) // no panic | |
| 123 | + | }) | |
| 110 | 124 | } | |
| 111 | 125 | ||
| 112 | 126 | func TestConcurrentPublishSubscribe(t *testing.T) { | |
| 113 | - | b := eventbus.NewWithBuffer(8) | |
| 114 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 115 | - | var wg sync.WaitGroup | |
| 116 | - | for range 4 { | |
| 117 | - | wg.Add(2) | |
| 118 | - | go func() { | |
| 119 | - | defer wg.Done() | |
| 120 | - | for range 200 { | |
| 121 | - | b.Publish(ctx, ev("x")) | |
| 122 | - | } | |
| 123 | - | }() | |
| 124 | - | go func() { | |
| 125 | - | defer wg.Done() | |
| 126 | - | sctx, scancel := context.WithCancel(ctx) | |
| 127 | - | ch := b.Subscribe(sctx) | |
| 128 | - | for range 5 { | |
| 129 | - | select { | |
| 130 | - | case <-ch: | |
| 131 | - | case <-time.After(10 * time.Millisecond): | |
| 127 | + | t.Parallel() | |
| 128 | + | synctest.Test(t, func(t *testing.T) { | |
| 129 | + | b := eventbus.NewWithBuffer(8) | |
| 130 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 131 | + | var wg sync.WaitGroup | |
| 132 | + | for range 4 { | |
| 133 | + | wg.Add(2) | |
| 134 | + | go func() { | |
| 135 | + | defer wg.Done() | |
| 136 | + | for range 200 { | |
| 137 | + | b.Publish(ctx, ev("x")) | |
| 132 | 138 | } | |
| 133 | - | } | |
| 134 | - | scancel() | |
| 135 | - | }() | |
| 136 | - | } | |
| 137 | - | wg.Wait() | |
| 138 | - | cancel() | |
| 139 | + | }() | |
| 140 | + | go func() { | |
| 141 | + | defer wg.Done() | |
| 142 | + | sctx, scancel := context.WithCancel(ctx) | |
| 143 | + | ch := b.Subscribe(sctx) | |
| 144 | + | for range 5 { | |
| 145 | + | select { | |
| 146 | + | case <-ch: | |
| 147 | + | default: // nothing buffered yet: Publish never blocks, nor does this | |
| 148 | + | } | |
| 149 | + | runtime.Gosched() | |
| 150 | + | } | |
| 151 | + | scancel() | |
| 152 | + | }() | |
| 153 | + | } | |
| 154 | + | wg.Wait() | |
| 155 | + | cancel() | |
| 156 | + | synctest.Wait() | |
| 157 | + | assert.Zero(t, b.Subscribers(), "every subscription ended") | |
| 158 | + | }) | |
| 139 | 159 | } | |
M internal/adapters/gitcli/backend.go +7 -0
| @@ -32,6 +32,8 @@ func HooksDir(dataDir string) string { return filepath.Join(dataDir, "run", "hoo | |||
| 32 | 32 | // core.hooksPath=<data>/run/hooks | |
| 33 | 33 | // receive.fsckObjects=true | |
| 34 | 34 | // uploadpack.allowFilter=true | |
| 35 | + | // pack.threads=1 | |
| 36 | + | // pack.windowMemory=256m | |
| 35 | 37 | // http.receivepack=true (push only) | |
| 36 | 38 | // receive.maxInputSize=<max> (push only, when configured) | |
| 37 | 39 | // | |
| @@ -45,6 +47,11 @@ func (g *Git) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, er | |||
| 45 | 47 | {"core.hooksPath", HooksDir(g.dataDir)}, | |
| 46 | 48 | {"receive.fsckObjects", "true"}, | |
| 47 | 49 | {"uploadpack.allowFilter", "true"}, | |
| 50 | + | // pack-objects defaults to one thread per CPU and an unbounded delta | |
| 51 | + | // window; with several backends at once that is the memory and CPU | |
| 52 | + | // of the host. The backends are bounded by the handler instead. | |
| 53 | + | {"pack.threads", "1"}, | |
| 54 | + | {"pack.windowMemory", "256m"}, | |
| 48 | 55 | } | |
| 49 | 56 | if push { | |
| 50 | 57 | conf = append(conf, [2]string{"http.receivepack", "true"}) | |
M internal/adapters/gitcli/backend_test.go +4 -0
| @@ -86,6 +86,8 @@ func TestHTTPBackendEnvironment(t *testing.T) { | |||
| 86 | 86 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | |
| 87 | 87 | "receive.fsckObjects": "true", | |
| 88 | 88 | "uploadpack.allowFilter": "true", | |
| 89 | + | "pack.threads": "1", | |
| 90 | + | "pack.windowMemory": "256m", | |
| 89 | 91 | }, backendConf(e)) | |
| 90 | 92 | ||
| 91 | 93 | push, err := g.HTTPBackend(ctx, true) | |
| @@ -96,6 +98,8 @@ func TestHTTPBackendEnvironment(t *testing.T) { | |||
| 96 | 98 | "core.hooksPath": filepath.Join(data, "run", "hooks"), | |
| 97 | 99 | "receive.fsckObjects": "true", | |
| 98 | 100 | "uploadpack.allowFilter": "true", | |
| 101 | + | "pack.threads": "1", | |
| 102 | + | "pack.windowMemory": "256m", | |
| 99 | 103 | "http.receivepack": "true", | |
| 100 | 104 | }, backendConf(pe), "http.receivepack only for a push; no size limit unless configured") | |
| 101 | 105 | ||
M internal/adapters/gitcli/catfile.go +175 -67
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "io" | |
| 12 | 12 | "os/exec" | |
| 13 | 13 | "path/filepath" | |
| 14 | + | "slices" | |
| 14 | 15 | "strconv" | |
| 15 | 16 | "strings" | |
| 16 | 17 | "sync" | |
| @@ -36,27 +37,40 @@ func (e *procError) Unwrap() error { return e.err } | |||
| 36 | 37 | const ( | |
| 37 | 38 | defaultIdleTimeout = 60 * time.Second | |
| 38 | 39 | defaultMaxProcesses = 16 | |
| 40 | + | // maxProcsPerRepo bounds the processes one repository may hold. A | |
| 41 | + | // popular repository spawns more than one only under contention. | |
| 42 | + | maxProcsPerRepo = 4 | |
| 39 | 43 | ) | |
| 40 | 44 | ||
| 41 | - | // catPool keeps one long-lived `git cat-file --batch-command` per repository. | |
| 45 | + | // catPool keeps long-lived `git cat-file --batch-command` processes, a few per | |
| 46 | + | // repository. | |
| 42 | 47 | // | |
| 43 | - | // - Access to one process is serialized: one request in flight at a time. | |
| 48 | + | // - A process serves one request at a time. A request takes an idle process | |
| 49 | + | // of the repository; if all are busy and the repository holds fewer than | |
| 50 | + | // perRepo, a new one is started (so a repository nobody reads | |
| 51 | + | // concurrently keeps a single process); otherwise the request waits, in | |
| 52 | + | // order, for a process to be released. | |
| 44 | 53 | // - A process nobody used for the idle timeout is killed. | |
| 45 | - | // - At most max processes are kept. Starting another one first evicts the | |
| 46 | - | // least recently used idle process; a process that is in use is never | |
| 47 | - | // evicted, so the bound is soft when more than max repositories are read | |
| 48 | - | // at the same moment. | |
| 54 | + | // - At most max processes are kept, all repositories together. Starting | |
| 55 | + | // another one first evicts the least recently used idle process; a | |
| 56 | + | // process that is in use is never evicted. When none can be evicted, a | |
| 57 | + | // repository that has a process waits for it, and one that has none starts | |
| 58 | + | // one anyway, so the bound is soft only when more than max repositories | |
| 59 | + | // are read at the same moment. | |
| 49 | 60 | // - A process that fails, or whose request was cancelled (its output is then | |
| 50 | 61 | // in an unknown state), is killed and dropped; the next request starts a | |
| 51 | 62 | // fresh one, and a request that hit a dead process is retried once. | |
| 52 | 63 | type catPool struct { | |
| 53 | - | bin string | |
| 54 | - | env []string | |
| 55 | - | idle time.Duration | |
| 56 | - | max int | |
| 64 | + | bin string | |
| 65 | + | env []string | |
| 66 | + | idle time.Duration | |
| 67 | + | max int // processes, all repositories together | |
| 68 | + | perRepo int // processes of one repository | |
| 57 | 69 | ||
| 58 | 70 | mu sync.Mutex | |
| 59 | - | procs map[string]*catProc | |
| 71 | + | procs map[string][]*catProc // by repository directory | |
| 72 | + | total int // live processes in procs | |
| 73 | + | waiters map[string][]chan *catProc | |
| 60 | 74 | streams map[*blobStream]struct{} // one-shot blob processes, see OpenBlob | |
| 61 | 75 | closed bool | |
| 62 | 76 | wg sync.WaitGroup // reapers of started processes | |
| @@ -71,11 +85,10 @@ type catProc struct { | |||
| 71 | 85 | in io.WriteCloser | |
| 72 | 86 | out *bufio.Reader | |
| 73 | 87 | stderr *tailBuffer | |
| 74 | - | sem chan struct{} // capacity 1: the request in flight | |
| 75 | 88 | ||
| 76 | - | broken bool // set by the holder of sem on a protocol error | |
| 89 | + | broken bool // set by the holder on a protocol error | |
| 77 | 90 | ||
| 78 | - | users int // requests waiting for or holding sem | |
| 91 | + | busy bool // a request holds the process | |
| 79 | 92 | lastUsed time.Time | |
| 80 | 93 | timer *time.Timer | |
| 81 | 94 | gen uint64 | |
| @@ -90,13 +103,14 @@ func newCatPool(bin string, env []string, idle time.Duration, maxProcs int) *cat | |||
| 90 | 103 | maxProcs = defaultMaxProcesses | |
| 91 | 104 | } | |
| 92 | 105 | return &catPool{ | |
| 93 | - | bin: bin, env: env, idle: idle, max: maxProcs, | |
| 94 | - | procs: make(map[string]*catProc), | |
| 106 | + | bin: bin, env: env, idle: idle, max: maxProcs, perRepo: min(maxProcsPerRepo, maxProcs), | |
| 107 | + | procs: make(map[string][]*catProc), | |
| 108 | + | waiters: make(map[string][]chan *catProc), | |
| 95 | 109 | streams: make(map[*blobStream]struct{}), | |
| 96 | 110 | } | |
| 97 | 111 | } | |
| 98 | 112 | ||
| 99 | - | // do runs fn with exclusive use of the cat-file process of the repository in | |
| 113 | + | // do runs fn with exclusive use of a cat-file process of the repository in | |
| 100 | 114 | // dir, starting it if needed. fn must not keep the connection after returning. | |
| 101 | 115 | func (p *catPool) do(ctx context.Context, dir string, fn func(*catConn) error) error { | |
| 102 | 116 | for attempt := 0; ; attempt++ { | |
| @@ -132,7 +146,6 @@ func (p *catPool) run(ctx context.Context, cp *catProc, fn func(*catConn) error) | |||
| 132 | 146 | err = ctx.Err() | |
| 133 | 147 | } | |
| 134 | 148 | p.release(cp, cp.broken || cancelled) | |
| 135 | - | <-cp.sem | |
| 136 | 149 | return err | |
| 137 | 150 | } | |
| 138 | 151 | ||
| @@ -142,58 +155,149 @@ func (p *catPool) isClosed() bool { | |||
| 142 | 155 | return p.closed | |
| 143 | 156 | } | |
| 144 | 157 | ||
| 158 | + | // acquire returns a process of dir that no other request uses. | |
| 145 | 159 | func (p *catPool) acquire(ctx context.Context, dir string) (*catProc, error) { | |
| 146 | 160 | for { | |
| 161 | + | if err := ctx.Err(); err != nil { | |
| 162 | + | return nil, err | |
| 163 | + | } | |
| 147 | 164 | p.mu.Lock() | |
| 148 | 165 | if p.closed { | |
| 149 | 166 | p.mu.Unlock() | |
| 150 | 167 | return nil, ErrClosed | |
| 151 | 168 | } | |
| 152 | - | cp := p.procs[dir] | |
| 153 | - | if cp == nil { | |
| 154 | - | var err error | |
| 155 | - | if cp, err = p.startLocked(ctx, dir); err != nil { | |
| 156 | - | p.mu.Unlock() | |
| 157 | - | return nil, err | |
| 158 | - | } | |
| 169 | + | cp, wait, err := p.takeLocked(ctx, dir) | |
| 170 | + | if err != nil { | |
| 171 | + | p.mu.Unlock() | |
| 172 | + | return nil, err | |
| 159 | 173 | } | |
| 160 | - | cp.users++ | |
| 161 | - | cp.gen++ | |
| 162 | - | if cp.timer != nil { | |
| 163 | - | cp.timer.Stop() | |
| 164 | - | cp.timer = nil | |
| 174 | + | if cp != nil { | |
| 175 | + | p.mu.Unlock() | |
| 176 | + | return cp, nil | |
| 165 | 177 | } | |
| 178 | + | p.waiters[dir] = append(p.waiters[dir], wait) | |
| 166 | 179 | p.mu.Unlock() | |
| 167 | 180 | ||
| 168 | 181 | select { | |
| 169 | - | case cp.sem <- struct{}{}: | |
| 182 | + | case cp := <-wait: | |
| 183 | + | if cp == nil { | |
| 184 | + | continue // the processes changed: look again | |
| 185 | + | } | |
| 186 | + | p.mu.Lock() | |
| 187 | + | dead := cp.dead | |
| 188 | + | p.mu.Unlock() | |
| 189 | + | if !dead { | |
| 190 | + | return cp, nil | |
| 191 | + | } | |
| 192 | + | p.release(cp, false) // dropped between the hand-over and now | |
| 170 | 193 | case <-ctx.Done(): | |
| 171 | - | p.release(cp, false) | |
| 194 | + | p.mu.Lock() | |
| 195 | + | if !p.unqueueLocked(dir, wait) { | |
| 196 | + | // A process was handed over meanwhile: pass it on. | |
| 197 | + | p.mu.Unlock() | |
| 198 | + | if cp := <-wait; cp != nil { | |
| 199 | + | p.release(cp, false) | |
| 200 | + | } | |
| 201 | + | } else { | |
| 202 | + | p.mu.Unlock() | |
| 203 | + | } | |
| 172 | 204 | return nil, ctx.Err() | |
| 173 | 205 | } | |
| 174 | - | p.mu.Lock() | |
| 175 | - | dead := cp.dead | |
| 176 | - | p.mu.Unlock() | |
| 177 | - | if !dead { | |
| 178 | - | return cp, nil | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | // takeLocked marks an idle process of dir busy and returns it, starting one | |
| 210 | + | // when none is idle and the repository may have more. Otherwise it returns the | |
| 211 | + | // channel to wait on. | |
| 212 | + | func (p *catPool) takeLocked(ctx context.Context, dir string) (*catProc, chan *catProc, error) { | |
| 213 | + | var best *catProc // the most recently used idle one, so that the others idle out | |
| 214 | + | for _, c := range p.procs[dir] { | |
| 215 | + | if !c.busy && (best == nil || c.lastUsed.After(best.lastUsed)) { | |
| 216 | + | best = c | |
| 179 | 217 | } | |
| 180 | - | // Dropped while we waited for our turn. | |
| 181 | - | <-cp.sem | |
| 182 | - | p.release(cp, false) | |
| 183 | 218 | } | |
| 219 | + | if best == nil { | |
| 220 | + | own := len(p.procs[dir]) | |
| 221 | + | if own < p.perRepo && (p.makeRoomLocked() || own == 0) { | |
| 222 | + | var err error | |
| 223 | + | if best, err = p.startLocked(ctx, dir); err != nil { | |
| 224 | + | return nil, nil, err | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | } | |
| 228 | + | if best == nil { | |
| 229 | + | return nil, make(chan *catProc, 1), nil | |
| 230 | + | } | |
| 231 | + | best.busy = true | |
| 232 | + | best.gen++ | |
| 233 | + | if best.timer != nil { | |
| 234 | + | best.timer.Stop() | |
| 235 | + | best.timer = nil | |
| 236 | + | } | |
| 237 | + | return best, nil, nil | |
| 184 | 238 | } | |
| 185 | 239 | ||
| 186 | - | // release ends one use of cp. A broken process is dropped; an unused one | |
| 187 | - | // starts its idle countdown. | |
| 240 | + | // makeRoomLocked evicts least recently used idle processes until another one | |
| 241 | + | // fits, and reports whether it does. | |
| 242 | + | func (p *catPool) makeRoomLocked() bool { | |
| 243 | + | for p.total >= p.max { | |
| 244 | + | var victim *catProc | |
| 245 | + | for _, procs := range p.procs { | |
| 246 | + | for _, c := range procs { | |
| 247 | + | if !c.busy && (victim == nil || c.lastUsed.Before(victim.lastUsed)) { | |
| 248 | + | victim = c | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | } | |
| 252 | + | if victim == nil { | |
| 253 | + | return false | |
| 254 | + | } | |
| 255 | + | victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used") | |
| 256 | + | p.dropLocked(victim) | |
| 257 | + | } | |
| 258 | + | return true | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // unqueueLocked removes a waiter and reports whether it was still queued. | |
| 262 | + | func (p *catPool) unqueueLocked(dir string, wait chan *catProc) bool { | |
| 263 | + | q := p.waiters[dir] | |
| 264 | + | for i, w := range q { | |
| 265 | + | if w == wait { | |
| 266 | + | q = append(q[:i:i], q[i+1:]...) | |
| 267 | + | if len(q) == 0 { | |
| 268 | + | delete(p.waiters, dir) | |
| 269 | + | } else { | |
| 270 | + | p.waiters[dir] = q | |
| 271 | + | } | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // release ends one use of cp. A broken process is dropped. A working one goes | |
| 279 | + | // to the request that waited longest, or else starts its idle countdown. | |
| 188 | 280 | func (p *catPool) release(cp *catProc, broken bool) { | |
| 189 | 281 | p.mu.Lock() | |
| 190 | 282 | defer p.mu.Unlock() | |
| 191 | - | cp.users-- | |
| 192 | - | if broken { | |
| 283 | + | if broken || cp.dead { | |
| 284 | + | cp.busy = false | |
| 193 | 285 | p.dropLocked(cp) | |
| 194 | 286 | return | |
| 195 | 287 | } | |
| 196 | - | if cp.users == 0 && !cp.dead && !p.closed { | |
| 288 | + | if q := p.waiters[cp.dir]; len(q) > 0 && !p.closed { | |
| 289 | + | w := q[0] | |
| 290 | + | if len(q) == 1 { | |
| 291 | + | delete(p.waiters, cp.dir) | |
| 292 | + | } else { | |
| 293 | + | p.waiters[cp.dir] = q[1:] | |
| 294 | + | } | |
| 295 | + | cp.gen++ | |
| 296 | + | w <- cp // stays busy: handed over | |
| 297 | + | return | |
| 298 | + | } | |
| 299 | + | cp.busy = false | |
| 300 | + | if !p.closed { | |
| 197 | 301 | cp.lastUsed = time.Now() | |
| 198 | 302 | gen := cp.gen | |
| 199 | 303 | cp.timer = time.AfterFunc(p.idle, func() { p.expire(cp, gen) }) | |
| @@ -204,7 +308,7 @@ func (p *catPool) release(cp *catProc, broken bool) { | |||
| 204 | 308 | func (p *catPool) expire(cp *catProc, gen uint64) { | |
| 205 | 309 | p.mu.Lock() | |
| 206 | 310 | defer p.mu.Unlock() | |
| 207 | - | if cp.gen != gen || cp.users != 0 || cp.dead { | |
| 311 | + | if cp.gen != gen || cp.busy || cp.dead { | |
| 208 | 312 | return | |
| 209 | 313 | } | |
| 210 | 314 | cp.log.Debug().Str("dir", cp.dir).Msg("cat-file idle, stopping") | |
| @@ -212,14 +316,24 @@ func (p *catPool) expire(cp *catProc, gen uint64) { | |||
| 212 | 316 | } | |
| 213 | 317 | ||
| 214 | 318 | // dropLocked removes cp from the pool and kills it. The reaper goroutine | |
| 215 | - | // collects the process. | |
| 319 | + | // collects the process. Requests waiting for the repository look again. | |
| 216 | 320 | func (p *catPool) dropLocked(cp *catProc) { | |
| 217 | 321 | if cp.dead { | |
| 218 | 322 | return | |
| 219 | 323 | } | |
| 220 | 324 | cp.dead = true | |
| 221 | - | if p.procs[cp.dir] == cp { | |
| 325 | + | procs := p.procs[cp.dir] | |
| 326 | + | for i, c := range procs { | |
| 327 | + | if c == cp { | |
| 328 | + | procs = append(procs[:i:i], procs[i+1:]...) | |
| 329 | + | p.total-- | |
| 330 | + | break | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | if len(procs) == 0 { | |
| 222 | 334 | delete(p.procs, cp.dir) | |
| 335 | + | } else { | |
| 336 | + | p.procs[cp.dir] = procs | |
| 223 | 337 | } | |
| 224 | 338 | if cp.timer != nil { | |
| 225 | 339 | cp.timer.Stop() | |
| @@ -227,22 +341,13 @@ func (p *catPool) dropLocked(cp *catProc) { | |||
| 227 | 341 | } | |
| 228 | 342 | _ = cp.in.Close() | |
| 229 | 343 | _ = cp.cmd.Process.Kill() | |
| 344 | + | for _, w := range p.waiters[cp.dir] { | |
| 345 | + | w <- nil | |
| 346 | + | } | |
| 347 | + | delete(p.waiters, cp.dir) | |
| 230 | 348 | } | |
| 231 | 349 | ||
| 232 | 350 | func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) { | |
| 233 | - | for len(p.procs) >= p.max { | |
| 234 | - | var victim *catProc | |
| 235 | - | for _, c := range p.procs { | |
| 236 | - | if c.users == 0 && (victim == nil || c.lastUsed.Before(victim.lastUsed)) { | |
| 237 | - | victim = c | |
| 238 | - | } | |
| 239 | - | } | |
| 240 | - | if victim == nil { | |
| 241 | - | break // all in use: exceed the bound rather than block | |
| 242 | - | } | |
| 243 | - | victim.log.Debug().Str("dir", victim.dir).Msg("cat-file pool full, evicting the least recently used") | |
| 244 | - | p.dropLocked(victim) | |
| 245 | - | } | |
| 246 | 351 | cmd := exec.Command(p.bin, "--git-dir="+dir, "--no-replace-objects", "--no-optional-locks", "cat-file", "--batch-command="+catFormat, "-Z") | |
| 247 | 352 | cmd.Env = p.env | |
| 248 | 353 | cmd.Dir = filepath.Dir(dir) | |
| @@ -261,9 +366,10 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) | |||
| 261 | 366 | } | |
| 262 | 367 | cp := &catProc{ | |
| 263 | 368 | dir: dir, log: zerowrap.FromCtx(ctx), cmd: cmd, in: in, out: bufio.NewReaderSize(stdout, 64*1024), | |
| 264 | - | stderr: stderr, sem: make(chan struct{}, 1), lastUsed: time.Now(), | |
| 369 | + | stderr: stderr, lastUsed: time.Now(), | |
| 265 | 370 | } | |
| 266 | - | p.procs[dir] = cp | |
| 371 | + | p.procs[dir] = append(p.procs[dir], cp) | |
| 372 | + | p.total++ | |
| 267 | 373 | p.wg.Add(1) | |
| 268 | 374 | go func() { | |
| 269 | 375 | defer p.wg.Done() | |
| @@ -273,11 +379,11 @@ func (p *catPool) startLocked(ctx context.Context, dir string) (*catProc, error) | |||
| 273 | 379 | return cp, nil | |
| 274 | 380 | } | |
| 275 | 381 | ||
| 276 | - | // forget kills the process of the repository in dir, if any. | |
| 382 | + | // forget kills the processes of the repository in dir, if any. | |
| 277 | 383 | func (p *catPool) forget(dir string) { | |
| 278 | 384 | p.mu.Lock() | |
| 279 | 385 | defer p.mu.Unlock() | |
| 280 | - | if cp := p.procs[dir]; cp != nil { | |
| 386 | + | for _, cp := range slices.Clone(p.procs[dir]) { | |
| 281 | 387 | p.dropLocked(cp) | |
| 282 | 388 | } | |
| 283 | 389 | } | |
| @@ -287,8 +393,10 @@ func (p *catPool) forget(dir string) { | |||
| 287 | 393 | func (p *catPool) close(ctx context.Context) error { | |
| 288 | 394 | p.mu.Lock() | |
| 289 | 395 | p.closed = true | |
| 290 | - | for _, cp := range p.procs { | |
| 291 | - | p.dropLocked(cp) | |
| 396 | + | for _, procs := range p.procs { | |
| 397 | + | for _, cp := range slices.Clone(procs) { | |
| 398 | + | p.dropLocked(cp) | |
| 399 | + | } | |
| 292 | 400 | } | |
| 293 | 401 | streams := make([]*blobStream, 0, len(p.streams)) | |
| 294 | 402 | for s := range p.streams { | |
M internal/adapters/gitcli/export_test.go +40 -7
| @@ -2,7 +2,12 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package gitcli | |
| 4 | 4 | ||
| 5 | - | import "sort" | |
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "sort" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | ) | |
| 6 | 11 | ||
| 7 | 12 | // CatFilePIDs returns the process ids of the live long-lived cat-file | |
| 8 | 13 | // processes, for the tests of the pool. | |
| @@ -10,8 +15,10 @@ func (g *Git) CatFilePIDs() []int { | |||
| 10 | 15 | g.cat.mu.Lock() | |
| 11 | 16 | defer g.cat.mu.Unlock() | |
| 12 | 17 | var pids []int | |
| 13 | - | for _, cp := range g.cat.procs { | |
| 14 | - | pids = append(pids, cp.cmd.Process.Pid) | |
| 18 | + | for _, procs := range g.cat.procs { | |
| 19 | + | for _, cp := range procs { | |
| 20 | + | pids = append(pids, cp.cmd.Process.Pid) | |
| 21 | + | } | |
| 15 | 22 | } | |
| 16 | 23 | sort.Ints(pids) | |
| 17 | 24 | return pids | |
| @@ -44,8 +51,10 @@ func (g *Git) FireIdleTimers() { | |||
| 44 | 51 | gen uint64 | |
| 45 | 52 | } | |
| 46 | 53 | var all []armed | |
| 47 | - | for _, cp := range g.cat.procs { | |
| 48 | - | all = append(all, armed{cp, cp.gen}) | |
| 54 | + | for _, procs := range g.cat.procs { | |
| 55 | + | for _, cp := range procs { | |
| 56 | + | all = append(all, armed{cp, cp.gen}) | |
| 57 | + | } | |
| 49 | 58 | } | |
| 50 | 59 | g.cat.mu.Unlock() | |
| 51 | 60 | for _, a := range all { | |
| @@ -62,8 +71,10 @@ func (g *Git) FireStaleIdleTimers() { | |||
| 62 | 71 | gen uint64 | |
| 63 | 72 | } | |
| 64 | 73 | var all []armed | |
| 65 | - | for _, cp := range g.cat.procs { | |
| 66 | - | all = append(all, armed{cp, cp.gen - 1}) | |
| 74 | + | for _, procs := range g.cat.procs { | |
| 75 | + | for _, cp := range procs { | |
| 76 | + | all = append(all, armed{cp, cp.gen - 1}) | |
| 77 | + | } | |
| 67 | 78 | } | |
| 68 | 79 | g.cat.mu.Unlock() | |
| 69 | 80 | for _, a := range all { | |
| @@ -74,3 +85,25 @@ func (g *Git) FireStaleIdleTimers() { | |||
| 74 | 85 | // ExecSlots returns the size of the one-shot process semaphore and how many | |
| 75 | 86 | // slots are taken. | |
| 76 | 87 | func (g *Git) ExecSlots() (size, taken int) { return cap(g.exec), len(g.exec) } | |
| 88 | + | ||
| 89 | + | // HoldCatFile runs fn while holding a cat-file process of the repository, as | |
| 90 | + | // a request in flight would. | |
| 91 | + | func (g *Git) HoldCatFile(ctx context.Context, repo domain.RepoName, fn func()) error { | |
| 92 | + | dir, err := g.repoDir(repo) | |
| 93 | + | if err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | return g.cat.do(ctx, dir, func(*catConn) error { fn(); return nil }) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // CatFileWaiting returns the number of requests that wait for a cat-file | |
| 100 | + | // process. | |
| 101 | + | func (g *Git) CatFileWaiting() int { | |
| 102 | + | g.cat.mu.Lock() | |
| 103 | + | defer g.cat.mu.Unlock() | |
| 104 | + | n := 0 | |
| 105 | + | for _, q := range g.cat.waiters { | |
| 106 | + | n += len(q) | |
| 107 | + | } | |
| 108 | + | return n | |
| 109 | + | } | |
M internal/adapters/gitcli/pool_test.go +166 -2
| @@ -177,7 +177,7 @@ func TestPoolRestartsAfterProcessDeath(t *testing.T) { | |||
| 177 | 177 | assert.NotEqual(t, old, now) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | - | func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |
| 180 | + | func TestConcurrentReadsAreCorrectAndBounded(t *testing.T) { | |
| 181 | 181 | ctx := context.Background() | |
| 182 | 182 | g, data := newReader(t, nil) | |
| 183 | 183 | h := readertest.NewHistory(t, data) | |
| @@ -223,7 +223,8 @@ func TestConcurrentReadsAreSerializedPerProcess(t *testing.T) { | |||
| 223 | 223 | for err := range errs { | |
| 224 | 224 | require.NoError(t, err) | |
| 225 | 225 | } | |
| 226 | - | assert.Len(t, g.CatFilePIDs(), 1, "one process for one repository") | |
| 226 | + | assert.NotEmpty(t, g.CatFilePIDs()) | |
| 227 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), 4, "a repository holds a bounded number of processes") | |
| 227 | 228 | } | |
| 228 | 229 | ||
| 229 | 230 | func TestCancelledRequestDoesNotPoisonTheProcess(t *testing.T) { | |
| @@ -406,3 +407,166 @@ func TestBlobStreamsAreBounded(t *testing.T) { | |||
| 406 | 407 | require.NoError(t, c.Close()) | |
| 407 | 408 | assert.Zero(t, g.BlobStreams()) | |
| 408 | 409 | } | |
| 410 | + | ||
| 411 | + | // hold runs HoldCatFile in the background and returns once the process is | |
| 412 | + | // held. The returned func releases it and waits for the request to end. | |
| 413 | + | func hold(t *testing.T, g *gitcli.Git, repo domain.RepoName) (release func()) { | |
| 414 | + | t.Helper() | |
| 415 | + | held, free, done := make(chan struct{}), make(chan struct{}), make(chan error, 1) | |
| 416 | + | go func() { | |
| 417 | + | done <- g.HoldCatFile(context.Background(), repo, func() { close(held); <-free }) | |
| 418 | + | }() | |
| 419 | + | select { | |
| 420 | + | case <-held: | |
| 421 | + | case err := <-done: | |
| 422 | + | require.NoError(t, err) | |
| 423 | + | t.Fatal("the request ended before it held a process") | |
| 424 | + | } | |
| 425 | + | var once sync.Once | |
| 426 | + | release = func() { | |
| 427 | + | once.Do(func() { close(free); require.NoError(t, <-done) }) | |
| 428 | + | } | |
| 429 | + | t.Cleanup(release) | |
| 430 | + | return release | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // A request in flight does not make the others of the repository wait: they | |
| 434 | + | // get a process of their own, started only because of the contention. | |
| 435 | + | func TestConcurrentReadsOnOneRepoDoNotSerialize(t *testing.T) { | |
| 436 | + | ctx := context.Background() | |
| 437 | + | g, data := newReader(t, nil) | |
| 438 | + | h := readertest.NewHistory(t, data) | |
| 439 | + | ||
| 440 | + | _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) | |
| 441 | + | require.NoError(t, err) | |
| 442 | + | require.Len(t, g.CatFilePIDs(), 1, "no contention, one process") | |
| 443 | + | ||
| 444 | + | release := hold(t, g, h.Name) | |
| 445 | + | // The slow request holds the only process; this one must not wait for it | |
| 446 | + | // (it would block forever: the holder is released after). | |
| 447 | + | res, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}) | |
| 448 | + | require.NoError(t, err) | |
| 449 | + | assert.Equal(t, h.Merge, res.CommitID.String()) | |
| 450 | + | pids := g.CatFilePIDs() | |
| 451 | + | assert.Len(t, pids, 2, "the contention started a second process") | |
| 452 | + | assert.Zero(t, g.CatFileWaiting()) | |
| 453 | + | ||
| 454 | + | release() | |
| 455 | + | // Both stay for reuse until they idle out. | |
| 456 | + | assert.Equal(t, pids, g.CatFilePIDs()) | |
| 457 | + | g.FireIdleTimers() | |
| 458 | + | assert.Empty(t, g.CatFilePIDs()) | |
| 459 | + | requireGone(t, pids...) | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func TestCatFileProcessBounds(t *testing.T) { | |
| 463 | + | tests := []struct { | |
| 464 | + | name string | |
| 465 | + | max int // Config.MaxCatFiles | |
| 466 | + | // holds are the repositories, by index, whose process a request holds. | |
| 467 | + | holds []int | |
| 468 | + | // extra is the repository of one more request, which must wait. | |
| 469 | + | extra int | |
| 470 | + | wantProcs int | |
| 471 | + | wantWaiting int | |
| 472 | + | }{ | |
| 473 | + | {name: "per repository cap", max: 16, holds: []int{0, 0, 0, 0}, extra: 0, wantProcs: 4, wantWaiting: 1}, | |
| 474 | + | {name: "global cap, repository with a process waits", max: 3, holds: []int{0, 0, 1}, extra: 1, wantProcs: 3, wantWaiting: 1}, | |
| 475 | + | {name: "global cap lower than the per repository cap", max: 2, holds: []int{0, 0}, extra: 0, wantProcs: 2, wantWaiting: 1}, | |
| 476 | + | } | |
| 477 | + | for _, tt := range tests { | |
| 478 | + | t.Run(tt.name, func(t *testing.T) { | |
| 479 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = tt.max }) | |
| 480 | + | var names []domain.RepoName | |
| 481 | + | for i := range 2 { | |
| 482 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) | |
| 483 | + | f.Write("x", "x").Commit("x") | |
| 484 | + | names = append(names, f.Name) | |
| 485 | + | } | |
| 486 | + | var releases []func() | |
| 487 | + | for _, i := range tt.holds { | |
| 488 | + | releases = append(releases, hold(t, g, names[i])) | |
| 489 | + | } | |
| 490 | + | assert.Len(t, g.CatFilePIDs(), tt.wantProcs) | |
| 491 | + | ||
| 492 | + | done := make(chan error, 1) | |
| 493 | + | go func() { | |
| 494 | + | _, err := g.ResolveRevision(context.Background(), names[tt.extra], domain.Revision{}) | |
| 495 | + | done <- err | |
| 496 | + | }() | |
| 497 | + | require.Eventually(t, func() bool { return g.CatFileWaiting() == tt.wantWaiting }, 5*time.Second, time.Millisecond) | |
| 498 | + | assert.Len(t, g.CatFilePIDs(), tt.wantProcs, "a waiting request starts nothing") | |
| 499 | + | select { | |
| 500 | + | case err := <-done: | |
| 501 | + | t.Fatalf("the request did not wait: %v", err) | |
| 502 | + | default: | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | // A process released to a waiter serves it, without a new process. | |
| 506 | + | for k, i := range tt.holds { | |
| 507 | + | if i == tt.extra { | |
| 508 | + | releases[k]() | |
| 509 | + | break | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | require.NoError(t, <-done) | |
| 513 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), tt.wantProcs) | |
| 514 | + | assert.Zero(t, g.CatFileWaiting()) | |
| 515 | + | }) | |
| 516 | + | } | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | // A waiting request gives up with its context and leaves nothing queued. | |
| 520 | + | func TestWaitingForAProcessEndsWithTheContext(t *testing.T) { | |
| 521 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 1 }) | |
| 522 | + | h := readertest.NewHistory(t, data) | |
| 523 | + | release := hold(t, g, h.Name) | |
| 524 | + | ||
| 525 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 526 | + | done := make(chan error, 1) | |
| 527 | + | go func() { _, err := g.ResolveRevision(ctx, h.Name, domain.Revision{}); done <- err }() | |
| 528 | + | require.Eventually(t, func() bool { return g.CatFileWaiting() == 1 }, 5*time.Second, time.Millisecond) | |
| 529 | + | cancel() | |
| 530 | + | assert.ErrorIs(t, <-done, context.Canceled) | |
| 531 | + | assert.Zero(t, g.CatFileWaiting()) | |
| 532 | + | assert.Len(t, g.CatFilePIDs(), 1) | |
| 533 | + | ||
| 534 | + | release() | |
| 535 | + | _, err := g.ResolveRevision(context.Background(), h.Name, domain.Revision{}) | |
| 536 | + | require.NoError(t, err, "the process survived the abandoned wait") | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | // The bound counts processes, not repositories: idle processes of several | |
| 540 | + | // repositories are evicted to make room. | |
| 541 | + | func TestPoolBoundCountsProcessesAcrossRepositories(t *testing.T) { | |
| 542 | + | ctx := context.Background() | |
| 543 | + | g, data := newReader(t, func(c *gitcli.Config) { c.MaxCatFiles = 3 }) | |
| 544 | + | var names []domain.RepoName | |
| 545 | + | for i := range 4 { | |
| 546 | + | f := readertest.NewFixture(t, data, fmt.Sprintf("r%d", i)) | |
| 547 | + | f.Write("x", "x").Commit("x") | |
| 548 | + | names = append(names, f.Name) | |
| 549 | + | } | |
| 550 | + | // Repository 0 grows to three processes. | |
| 551 | + | releases := []func(){hold(t, g, names[0]), hold(t, g, names[0]), hold(t, g, names[0])} | |
| 552 | + | require.Len(t, g.CatFilePIDs(), 3) | |
| 553 | + | for _, r := range releases { | |
| 554 | + | r() | |
| 555 | + | } | |
| 556 | + | before := g.CatFilePIDs() | |
| 557 | + | ||
| 558 | + | for _, n := range names[1:] { | |
| 559 | + | _, err := g.ResolveRevision(ctx, n, domain.Revision{}) | |
| 560 | + | require.NoError(t, err) | |
| 561 | + | assert.LessOrEqual(t, len(g.CatFilePIDs()), 3) | |
| 562 | + | } | |
| 563 | + | live := map[int]bool{} | |
| 564 | + | for _, p := range g.CatFilePIDs() { | |
| 565 | + | live[p] = true | |
| 566 | + | } | |
| 567 | + | for _, p := range before { | |
| 568 | + | if !live[p] { | |
| 569 | + | requireGone(t, p) | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | } | |
M internal/adapters/githttp/handler.go +150 -42
| @@ -7,16 +7,21 @@ | |||
| 7 | 7 | package githttp | |
| 8 | 8 | ||
| 9 | 9 | import ( | |
| 10 | + | "cmp" | |
| 10 | 11 | "context" | |
| 11 | 12 | "errors" | |
| 13 | + | "io" | |
| 12 | 14 | "log" | |
| 13 | 15 | "math" | |
| 14 | 16 | "net/http" | |
| 15 | 17 | "net/http/cgi" | |
| 16 | 18 | "net/netip" | |
| 17 | 19 | "regexp" | |
| 20 | + | "runtime" | |
| 18 | 21 | "strconv" | |
| 19 | 22 | "strings" | |
| 23 | + | "sync" | |
| 24 | + | "time" | |
| 20 | 25 | ||
| 21 | 26 | "github.com/bnema/zerowrap" | |
| 22 | 27 | ||
| @@ -56,59 +61,63 @@ const ( | |||
| 56 | 61 | serviceReceive = "git-receive-pack" | |
| 57 | 62 | ) | |
| 58 | 63 | ||
| 59 | - | // Authenticator turns the secret of a request into a principal. It returns | |
| 60 | - | // domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits. | |
| 61 | - | type Authenticator interface { | |
| 62 | - | Authenticate(ctx context.Context, secret string) (domain.Principal, error) | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Repos is the read side of the repository use cases. A repository the | |
| 66 | - | // principal may not read is domain.ErrNotFound. app.Repos fits. | |
| 67 | - | type Repos interface { | |
| 68 | - | Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an | |
| 72 | - | // anonymous caller, domain.ErrNotFound for a repository the caller may not | |
| 73 | - | // write and cannot see (or that is missing and may not be created), | |
| 74 | - | // domain.ErrForbidden for one it can see but not write. With create, a missing | |
| 75 | - | // repository the principal may write is created. app.Pushes fits. | |
| 76 | - | type Pushes interface { | |
| 77 | - | Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // HookTokens hands out the per-request tokens the hooks of a push present | |
| 81 | - | // back to ermite, and the socket they present them on. | |
| 82 | - | type HookTokens interface { | |
| 83 | - | // Register binds a new token to the repository and principal of a push. | |
| 84 | - | // release revokes it and must be called when the request ends. | |
| 85 | - | Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (token string, release func()) | |
| 86 | - | // Socket is the path of the hook socket. | |
| 87 | - | Socket() string | |
| 88 | - | } | |
| 89 | - | ||
| 90 | 64 | // Config configures a Handler. | |
| 91 | 65 | type Config struct { | |
| 92 | - | Auth Authenticator | |
| 93 | - | Repos Repos | |
| 94 | - | Pushes Pushes | |
| 66 | + | Auth ports.AuthService | |
| 67 | + | Repos ports.RepoLookup | |
| 68 | + | Pushes ports.PushService | |
| 95 | 69 | Backend ports.GitBackend | |
| 96 | 70 | // Hooks provides hook tokens for pushes. It is required. | |
| 97 | - | Hooks HookTokens | |
| 71 | + | Hooks ports.HookTokens | |
| 98 | 72 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 99 | 73 | TrustedProxies []netip.Prefix | |
| 100 | 74 | // Limiter counts failed authentications; nil builds a default one. | |
| 101 | - | Limiter *clientlimit.Limiter | |
| 75 | + | Limiter ports.FailureLimiter | |
| 102 | 76 | // MaxBodyBytes bounds the body of a push; 0 means DefaultMaxBodyBytes. | |
| 103 | 77 | // The body of a fetch is bounded by 64 MiB at most. | |
| 104 | 78 | MaxBodyBytes int64 | |
| 79 | + | // MaxUploadPacks bounds the git-upload-pack backends (fetches, clones, | |
| 80 | + | // and the ref advertisement that precedes them) running at once; 0 means | |
| 81 | + | // 2*GOMAXPROCS. | |
| 82 | + | MaxUploadPacks int | |
| 83 | + | // MaxReceivePacks bounds the git-receive-pack backends (pushes) running | |
| 84 | + | // at once; 0 means max(2, GOMAXPROCS/2). | |
| 85 | + | MaxReceivePacks int | |
| 86 | + | // MaxPerClient bounds the backends running at once for one client (see | |
| 87 | + | // clientlimit.Key); 0 means DefaultMaxPerClient. | |
| 88 | + | MaxPerClient int | |
| 105 | 89 | } | |
| 106 | 90 | ||
| 91 | + | // Bounds on the git http-backend processes and on how long a client may take | |
| 92 | + | // to send a request body. | |
| 93 | + | const ( | |
| 94 | + | // DefaultMaxPerClient is the backends one client may run at once. | |
| 95 | + | DefaultMaxPerClient = 4 | |
| 96 | + | ||
| 97 | + | // retryAfter is the Retry-After, in seconds, of a request refused because | |
| 98 | + | // the backends are busy. | |
| 99 | + | retryAfter = "5" | |
| 100 | + | ||
| 101 | + | // fetchBodyTimeout is the whole time a client has to send the body of a | |
| 102 | + | // fetch, which is small (64 MiB at most). | |
| 103 | + | fetchBodyTimeout = 60 * time.Second | |
| 104 | + | // pushIdleTimeout is how long a push may go without sending a byte. A | |
| 105 | + | // push is bounded by its size, not by its duration, so the deadline moves | |
| 106 | + | // forward with every read. | |
| 107 | + | pushIdleTimeout = 2 * time.Minute | |
| 108 | + | ) | |
| 109 | + | ||
| 107 | 110 | // Handler serves the smart HTTP routes of Patterns. | |
| 108 | 111 | type Handler struct { | |
| 109 | 112 | cfg Config | |
| 110 | - | limiter *clientlimit.Limiter | |
| 113 | + | limiter ports.FailureLimiter | |
| 111 | 114 | maxBody int64 | |
| 115 | + | ||
| 116 | + | upload, receive chan struct{} // slots of the backends, per service | |
| 117 | + | ||
| 118 | + | mu sync.Mutex // guards clients | |
| 119 | + | clients map[string]int // backends running, per clientlimit.Key | |
| 120 | + | perCli int | |
| 112 | 121 | } | |
| 113 | 122 | ||
| 114 | 123 | // New builds a Handler. | |
| @@ -120,6 +129,11 @@ func New(cfg Config) *Handler { | |||
| 120 | 129 | if h.maxBody <= 0 { | |
| 121 | 130 | h.maxBody = DefaultMaxBodyBytes | |
| 122 | 131 | } | |
| 132 | + | procs := runtime.GOMAXPROCS(0) | |
| 133 | + | h.upload = make(chan struct{}, cmp.Or(cfg.MaxUploadPacks, 2*procs)) | |
| 134 | + | h.receive = make(chan struct{}, cmp.Or(cfg.MaxReceivePacks, max(2, procs/2))) | |
| 135 | + | h.perCli = cmp.Or(cfg.MaxPerClient, DefaultMaxPerClient) | |
| 136 | + | h.clients = make(map[string]int) | |
| 123 | 137 | return h | |
| 124 | 138 | } | |
| 125 | 139 | ||
| @@ -168,6 +182,8 @@ func parseRoute(r *http.Request) (route, int) { | |||
| 168 | 182 | } | |
| 169 | 183 | ||
| 170 | 184 | // ServeHTTP implements http.Handler. | |
| 185 | + | // | |
| 186 | + | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | |
| 171 | 187 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 172 | 188 | var rt route | |
| 173 | 189 | var name domain.RepoName | |
| @@ -225,7 +241,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 225 | 241 | return | |
| 226 | 242 | } | |
| 227 | 243 | ||
| 228 | - | h.serveBackend(w, r, rt, name, principal) | |
| 244 | + | h.serveBackend(w, r, rt, name, principal, clientlimit.Key(addr)) | |
| 229 | 245 | } | |
| 230 | 246 | ||
| 231 | 247 | // authenticate reads the credentials of the request. No credentials is the | |
| @@ -311,11 +327,66 @@ var inheritedEnv = []string{ | |||
| 311 | 327 | "LD_LIBRARY_PATH_32=", "LD_LIBRARY_PATH_64=", "DYLD_LIBRARY_PATH=", "SHLIB_PATH=", | |
| 312 | 328 | } | |
| 313 | 329 | ||
| 330 | + | // acquire takes a backend slot for the client and the service of rt, or | |
| 331 | + | // answers 429 (the client already runs its share) or 503 (the server runs all | |
| 332 | + | // it can) with a Retry-After and reports false. | |
| 333 | + | // | |
| 334 | + | // It never waits. A backend can run for minutes, so a queue would only trade | |
| 335 | + | // a refusal for a held connection and goroutine, and let a burst of requests | |
| 336 | + | // pile up behind slow clones. Git does not retry by itself, but the refusal is | |
| 337 | + | // cheap and says when to come back. release must be called when ok. | |
| 338 | + | func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, client string) (release func(), ok bool) { | |
| 339 | + | logger := zerowrap.FromCtx(ctx) | |
| 340 | + | refuse := func(status int, msg string) { | |
| 341 | + | w.Header().Set("Retry-After", retryAfter) | |
| 342 | + | http.Error(w, msg, status) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | h.mu.Lock() | |
| 346 | + | if h.clients[client] >= h.perCli { | |
| 347 | + | h.mu.Unlock() | |
| 348 | + | logger.Warn().Str("client", client).Msg("too many git requests in progress for the client") | |
| 349 | + | refuse(http.StatusTooManyRequests, "too many requests in progress") | |
| 350 | + | return nil, false | |
| 351 | + | } | |
| 352 | + | h.clients[client]++ | |
| 353 | + | h.mu.Unlock() | |
| 354 | + | releaseClient := func() { | |
| 355 | + | h.mu.Lock() | |
| 356 | + | if h.clients[client]--; h.clients[client] <= 0 { | |
| 357 | + | delete(h.clients, client) | |
| 358 | + | } | |
| 359 | + | h.mu.Unlock() | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | slots := h.upload | |
| 363 | + | if rt.push() { | |
| 364 | + | slots = h.receive | |
| 365 | + | } | |
| 366 | + | select { | |
| 367 | + | case slots <- struct{}{}: | |
| 368 | + | return func() { <-slots; releaseClient() }, true | |
| 369 | + | default: | |
| 370 | + | releaseClient() | |
| 371 | + | logger.Warn().Str("service", rt.service).Msg("all git backends busy, request refused") | |
| 372 | + | refuse(http.StatusServiceUnavailable, "server busy") | |
| 373 | + | return nil, false | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | ||
| 314 | 377 | // serveBackend runs git http-backend for an authorized request. | |
| 315 | - | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) { | |
| 378 | + | // | |
| 379 | + | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | |
| 380 | + | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | |
| 316 | 381 | ctx := r.Context() | |
| 317 | 382 | logger := zerowrap.FromCtx(ctx) | |
| 318 | 383 | ||
| 384 | + | release, ok := h.acquire(ctx, w, rt, client) | |
| 385 | + | if !ok { | |
| 386 | + | return | |
| 387 | + | } | |
| 388 | + | defer release() | |
| 389 | + | ||
| 319 | 390 | be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push()) | |
| 320 | 391 | if err != nil { | |
| 321 | 392 | logger.Error().Err(err).Msg("prepare git http-backend") | |
| @@ -324,8 +395,8 @@ func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, | |||
| 324 | 395 | } | |
| 325 | 396 | env := append(append([]string(nil), be.Env...), inheritedEnv...) | |
| 326 | 397 | if rt.suffix == serviceReceive { | |
| 327 | - | token, release := h.cfg.Hooks.Register(ctx, name, p) | |
| 328 | - | defer release() | |
| 398 | + | token, revoke := h.cfg.Hooks.Register(ctx, name, p) | |
| 399 | + | defer revoke() | |
| 329 | 400 | env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token) | |
| 330 | 401 | } | |
| 331 | 402 | ||
| @@ -383,7 +454,16 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout | |||
| 383 | 454 | req.Header.Set("Git-Protocol", v) | |
| 384 | 455 | } | |
| 385 | 456 | ||
| 386 | - | req.Body = http.MaxBytesReader(w, r.Body, limit) | |
| 457 | + | body := &deadlineBody{ | |
| 458 | + | ReadCloser: http.MaxBytesReader(w, r.Body, limit), | |
| 459 | + | rc: http.NewResponseController(w), | |
| 460 | + | } | |
| 461 | + | if rt.push() { | |
| 462 | + | body.idle = pushIdleTimeout | |
| 463 | + | } else { | |
| 464 | + | body.until = time.Now().Add(fetchBodyTimeout) | |
| 465 | + | } | |
| 466 | + | req.Body = body | |
| 387 | 467 | if r.ContentLength < 0 || len(r.TransferEncoding) > 0 { | |
| 388 | 468 | req.ContentLength = -1 | |
| 389 | 469 | req.TransferEncoding = nil | |
| @@ -391,6 +471,34 @@ func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt rout | |||
| 391 | 471 | return req, 0 | |
| 392 | 472 | } | |
| 393 | 473 | ||
| 474 | + | // deadlineBody bounds the time a client takes to send a request body, which | |
| 475 | + | // the server's own timeouts leave unbounded: a client that stalls would hold a | |
| 476 | + | // backend slot and its process forever. Until the body ends, the connection | |
| 477 | + | // read deadline is until, or when that is zero, idle after the last read. At | |
| 478 | + | // the end of the body the deadline is lifted again, so that it does not cut | |
| 479 | + | // the connection while the response is still being written. | |
| 480 | + | type deadlineBody struct { | |
| 481 | + | io.ReadCloser | |
| 482 | + | rc *http.ResponseController | |
| 483 | + | idle time.Duration // moves the deadline forward at each read, if non-zero | |
| 484 | + | until time.Time // fixed deadline, when idle is zero | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func (b *deadlineBody) Read(p []byte) (int, error) { | |
| 488 | + | d := b.until | |
| 489 | + | if b.idle > 0 { | |
| 490 | + | d = time.Now().Add(b.idle) | |
| 491 | + | } | |
| 492 | + | // Not every ResponseWriter can set a deadline (HTTP/2 can, wrappers | |
| 493 | + | // might not): then the body is simply not bounded here. | |
| 494 | + | _ = b.rc.SetReadDeadline(d) | |
| 495 | + | n, err := b.ReadCloser.Read(p) | |
| 496 | + | if err != nil { | |
| 497 | + | _ = b.rc.SetReadDeadline(time.Time{}) | |
| 498 | + | } | |
| 499 | + | return n, err | |
| 500 | + | } | |
| 501 | + | ||
| 394 | 502 | // logWriter sends what the Git process (or the CGI layer) writes to its | |
| 395 | 503 | // standard error to the logger of the request. | |
| 396 | 504 | type logWriter struct { | |
M internal/adapters/githttp/handler_test.go +183 -86
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "strings" | |
| 15 | 15 | "sync" | |
| 16 | 16 | "testing" | |
| 17 | + | "time" | |
| 17 | 18 | ||
| 18 | 19 | "github.com/stretchr/testify/assert" | |
| 19 | 20 | "github.com/stretchr/testify/mock" | |
| @@ -26,97 +27,152 @@ import ( | |||
| 26 | 27 | "bnema.dev/ermite/internal/ports/mocks" | |
| 27 | 28 | ) | |
| 28 | 29 | ||
| 29 | - | type fakeAuth struct { | |
| 30 | - | mu sync.Mutex | |
| 31 | - | seen []string | |
| 30 | + | // cgiScript is a stand-in for git-http-backend: it answers with the | |
| 31 | + | // environment and the body it received. Like Git, it reads the whole request | |
| 32 | + | // before it answers (net/http cannot read a request body once the response | |
| 33 | + | // has started). | |
| 34 | + | // | |
| 35 | + | // The script is written once, in TestMain, before any test runs: writing an | |
| 36 | + | // executable while parallel tests fork lets a child inherit the open write | |
| 37 | + | // descriptor, and exec then fails with ETXTBSY (golang/go#22315). | |
| 38 | + | func cgiScript(t *testing.T) string { | |
| 39 | + | t.Helper() | |
| 40 | + | require.NotEmpty(t, backendScript, "TestMain writes the backend script") | |
| 41 | + | return backendScript | |
| 32 | 42 | } | |
| 33 | 43 | ||
| 34 | - | func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) { | |
| 35 | - | f.mu.Lock() | |
| 36 | - | f.seen = append(f.seen, secret) | |
| 37 | - | f.mu.Unlock() | |
| 38 | - | switch secret { | |
| 39 | - | case "root-secret": | |
| 40 | - | return domain.Root(), nil | |
| 41 | - | case "reader-secret": | |
| 42 | - | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |
| 44 | + | var backendScript string | |
| 45 | + | ||
| 46 | + | func TestMain(m *testing.M) { | |
| 47 | + | os.Exit(runWithBackendScript(m)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func runWithBackendScript(m *testing.M) int { | |
| 51 | + | dir, err := os.MkdirTemp("", "githttp-backend") | |
| 52 | + | if err != nil { | |
| 53 | + | panic(err) | |
| 54 | + | } | |
| 55 | + | defer func() { _ = os.RemoveAll(dir) }() | |
| 56 | + | backendScript = filepath.Join(dir, "backend") | |
| 57 | + | body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n" | |
| 58 | + | if err := os.WriteFile(backendScript, []byte(body), 0o755); err != nil { | |
| 59 | + | panic(err) | |
| 43 | 60 | } | |
| 44 | - | return domain.Principal{}, domain.ErrUnauthenticated | |
| 61 | + | return m.Run() | |
| 45 | 62 | } | |
| 46 | 63 | ||
| 47 | - | type fakeRepos struct{} | |
| 64 | + | type rig struct { | |
| 65 | + | h *githttp.Handler | |
| 66 | + | srv *httptest.Server | |
| 67 | + | pushes *mocks.PushService | |
| 68 | + | backend *mocks.GitBackend | |
| 69 | + | hooks *mocks.HookTokens | |
| 70 | + | auth *mocks.AuthService | |
| 48 | 71 | ||
| 49 | - | func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |
| 50 | - | switch n.String() { | |
| 51 | - | case "pub": | |
| 52 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |
| 53 | - | case "priv": | |
| 54 | - | if p.Allows(domain.ScopeRepoRead, n) { | |
| 55 | - | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |
| 56 | - | } | |
| 57 | - | } | |
| 58 | - | return domain.Repo{}, domain.ErrNotFound | |
| 72 | + | pushFlags []bool | |
| 73 | + | backendErr error | |
| 74 | + | ||
| 75 | + | mu sync.Mutex // guards what the mocks record while the server runs | |
| 76 | + | pushErr error // what Pushes.Open answers a named caller | |
| 77 | + | pushCalls []bool // the create flag of each Pushes.Open | |
| 78 | + | authCalls int // Authenticator.Authenticate calls | |
| 79 | + | released int // hook tokens released | |
| 80 | + | hookRepo domain.RepoName | |
| 59 | 81 | } | |
| 60 | 82 | ||
| 61 | - | type fakePushes struct { | |
| 62 | - | mu sync.Mutex | |
| 63 | - | calls []bool // the create flag of each Open | |
| 64 | - | err error | |
| 83 | + | func (r *rig) setPushErr(err error) { | |
| 84 | + | r.mu.Lock() | |
| 85 | + | defer r.mu.Unlock() | |
| 86 | + | r.pushErr = err | |
| 65 | 87 | } | |
| 66 | 88 | ||
| 67 | - | func (f *fakePushes) Open(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |
| 68 | - | f.mu.Lock() | |
| 69 | - | defer f.mu.Unlock() | |
| 70 | - | f.calls = append(f.calls, create) | |
| 71 | - | if p.IsAnonymous() { | |
| 72 | - | return domain.ErrUnauthenticated | |
| 73 | - | } | |
| 74 | - | return f.err | |
| 89 | + | func (r *rig) takePushCalls() []bool { | |
| 90 | + | r.mu.Lock() | |
| 91 | + | defer r.mu.Unlock() | |
| 92 | + | c := r.pushCalls | |
| 93 | + | r.pushCalls = nil | |
| 94 | + | return c | |
| 75 | 95 | } | |
| 76 | 96 | ||
| 77 | - | type fakeTokens struct { | |
| 78 | - | mu sync.Mutex | |
| 79 | - | released int | |
| 80 | - | repo domain.RepoName | |
| 97 | + | func (r *rig) authenticateCalls() int { | |
| 98 | + | r.mu.Lock() | |
| 99 | + | defer r.mu.Unlock() | |
| 100 | + | return r.authCalls | |
| 81 | 101 | } | |
| 82 | 102 | ||
| 83 | - | func (f *fakeTokens) Register(_ context.Context, r domain.RepoName, _ domain.Principal) (string, func()) { | |
| 84 | - | f.mu.Lock() | |
| 85 | - | defer f.mu.Unlock() | |
| 86 | - | f.repo = r | |
| 87 | - | return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() } | |
| 103 | + | func (r *rig) releasedTokens() int { | |
| 104 | + | r.mu.Lock() | |
| 105 | + | defer r.mu.Unlock() | |
| 106 | + | return r.released | |
| 88 | 107 | } | |
| 89 | - | func (f *fakeTokens) Socket() string { return "/run/hook.sock" } | |
| 90 | 108 | ||
| 91 | - | // cgiScript is a stand-in for git-http-backend: it answers with the | |
| 92 | - | // environment and the body it received. Like Git, it reads the whole request | |
| 93 | - | // before it answers (net/http cannot read a request body once the response | |
| 94 | - | // has started). | |
| 95 | - | func cgiScript(t *testing.T) string { | |
| 96 | - | t.Helper() | |
| 97 | - | p := filepath.Join(t.TempDir(), "backend") | |
| 98 | - | body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n" | |
| 99 | - | require.NoError(t, os.WriteFile(p, []byte(body), 0o755)) | |
| 100 | - | return p | |
| 109 | + | func (r *rig) hookedRepo() domain.RepoName { | |
| 110 | + | r.mu.Lock() | |
| 111 | + | defer r.mu.Unlock() | |
| 112 | + | return r.hookRepo | |
| 101 | 113 | } | |
| 102 | 114 | ||
| 103 | - | type rig struct { | |
| 104 | - | h *githttp.Handler | |
| 105 | - | srv *httptest.Server | |
| 106 | - | pushes *fakePushes | |
| 107 | - | backend *mocks.GitBackend | |
| 108 | - | hooks *fakeTokens | |
| 109 | - | auth *fakeAuth | |
| 115 | + | // stubUseCases answers like the use cases behind the handler: a root and a | |
| 116 | + | // reader token, a public repository "pub" and a private one "priv". | |
| 117 | + | func (r *rig) stubUseCases(repos *mocks.RepoLookup) { | |
| 118 | + | r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn( | |
| 119 | + | func(_ context.Context, secret string) (domain.Principal, error) { | |
| 120 | + | r.mu.Lock() | |
| 121 | + | r.authCalls++ | |
| 122 | + | r.mu.Unlock() | |
| 123 | + | switch secret { | |
| 124 | + | case "root-secret": | |
| 125 | + | return domain.Root(), nil | |
| 126 | + | case "reader-secret": | |
| 127 | + | return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil | |
| 128 | + | } | |
| 129 | + | return domain.Principal{}, domain.ErrUnauthenticated | |
| 130 | + | }).Maybe() | |
| 110 | 131 | ||
| 111 | - | pushFlags []bool | |
| 112 | - | backendErr error | |
| 132 | + | repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 133 | + | func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) { | |
| 134 | + | switch n.String() { | |
| 135 | + | case "pub": | |
| 136 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil | |
| 137 | + | case "priv": | |
| 138 | + | if p.Allows(domain.ScopeRepoRead, n) { | |
| 139 | + | return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | return domain.Repo{}, domain.ErrNotFound | |
| 143 | + | }).Maybe() | |
| 144 | + | ||
| 145 | + | r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 146 | + | func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error { | |
| 147 | + | r.mu.Lock() | |
| 148 | + | defer r.mu.Unlock() | |
| 149 | + | r.pushCalls = append(r.pushCalls, create) | |
| 150 | + | if p.IsAnonymous() { | |
| 151 | + | return domain.ErrUnauthenticated | |
| 152 | + | } | |
| 153 | + | return r.pushErr | |
| 154 | + | }).Maybe() | |
| 155 | + | ||
| 156 | + | r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 157 | + | func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) { | |
| 158 | + | r.mu.Lock() | |
| 159 | + | defer r.mu.Unlock() | |
| 160 | + | r.hookRepo = n | |
| 161 | + | return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() } | |
| 162 | + | }).Maybe() | |
| 163 | + | r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe() | |
| 113 | 164 | } | |
| 114 | 165 | ||
| 115 | 166 | func newRig(t *testing.T, mut func(*githttp.Config)) *rig { | |
| 116 | 167 | t.Helper() | |
| 117 | - | r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}} | |
| 168 | + | r := &rig{ | |
| 169 | + | pushes: mocks.NewPushService(t), backend: mocks.NewGitBackend(t), | |
| 170 | + | hooks: mocks.NewHookTokens(t), auth: mocks.NewAuthService(t), | |
| 171 | + | } | |
| 172 | + | repos := mocks.NewRepoLookup(t) | |
| 173 | + | r.stubUseCases(repos) | |
| 118 | 174 | cfg := githttp.Config{ | |
| 119 | - | Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 175 | + | Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks, | |
| 120 | 176 | } | |
| 121 | 177 | script := cgiScript(t) | |
| 122 | 178 | r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |
| @@ -171,7 +227,7 @@ func envOf(body string) map[string]string { | |||
| 171 | 227 | } | |
| 172 | 228 | ||
| 173 | 229 | func TestHandlerRoutesAndStatuses(t *testing.T) { | |
| 174 | - | r := newRig(t, nil) | |
| 230 | + | t.Parallel() | |
| 175 | 231 | tests := []struct { | |
| 176 | 232 | name string | |
| 177 | 233 | method string | |
| @@ -200,6 +256,8 @@ func TestHandlerRoutesAndStatuses(t *testing.T) { | |||
| 200 | 256 | } | |
| 201 | 257 | for _, tt := range tests { | |
| 202 | 258 | t.Run(tt.name, func(t *testing.T) { | |
| 259 | + | t.Parallel() | |
| 260 | + | r := newRig(t, nil) // a rig per subtest: its mocks record without locking | |
| 203 | 261 | user := "" | |
| 204 | 262 | if tt.pass != "" { | |
| 205 | 263 | user = "whatever" // the username is ignored | |
| @@ -216,31 +274,31 @@ func TestHandlerRoutesAndStatuses(t *testing.T) { | |||
| 216 | 274 | } | |
| 217 | 275 | ||
| 218 | 276 | func TestHandlerPushAuthorizationAnswers(t *testing.T) { | |
| 277 | + | t.Parallel() | |
| 219 | 278 | r := newRig(t, nil) | |
| 220 | 279 | get := func() int { | |
| 221 | 280 | code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | |
| 222 | 281 | return code | |
| 223 | 282 | } | |
| 224 | 283 | assert.Equal(t, 200, get()) | |
| 225 | - | r.pushes.err = domain.ErrForbidden | |
| 284 | + | r.setPushErr(domain.ErrForbidden) | |
| 226 | 285 | assert.Equal(t, 403, get()) | |
| 227 | - | r.pushes.err = domain.ErrNotFound | |
| 286 | + | r.setPushErr(domain.ErrNotFound) | |
| 228 | 287 | assert.Equal(t, 404, get()) | |
| 229 | - | r.pushes.err = domain.ErrUnauthenticated | |
| 288 | + | r.setPushErr(domain.ErrUnauthenticated) | |
| 230 | 289 | assert.Equal(t, 401, get()) | |
| 231 | - | r.pushes.err = errBoom{} | |
| 290 | + | r.setPushErr(errBoom{}) | |
| 232 | 291 | assert.Equal(t, 500, get()) | |
| 233 | 292 | ||
| 234 | 293 | // Only the first request of a push may create. | |
| 235 | - | r.pushes.err = nil | |
| 236 | - | r.pushes.calls = nil | |
| 294 | + | r.setPushErr(nil) | |
| 295 | + | r.takePushCalls() | |
| 237 | 296 | r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | |
| 238 | 297 | r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"}) | |
| 239 | - | assert.Equal(t, []bool{true, false}, r.pushes.calls) | |
| 298 | + | assert.Equal(t, []bool{true, false}, r.takePushCalls()) | |
| 240 | 299 | // Reads never reach the push use case. | |
| 241 | - | r.pushes.calls = nil | |
| 242 | 300 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 243 | - | assert.Empty(t, r.pushes.calls) | |
| 301 | + | assert.Empty(t, r.takePushCalls()) | |
| 244 | 302 | } | |
| 245 | 303 | ||
| 246 | 304 | type errBoom struct{} | |
| @@ -248,6 +306,7 @@ type errBoom struct{} | |||
| 248 | 306 | func (errBoom) Error() string { return "boom with /internal/detail" } | |
| 249 | 307 | ||
| 250 | 308 | func TestHandlerBackendEnvironment(t *testing.T) { | |
| 309 | + | t.Parallel() | |
| 251 | 310 | r := newRig(t, nil) | |
| 252 | 311 | hdr := map[string]string{ | |
| 253 | 312 | "Cookie": "session=secret", | |
| @@ -295,21 +354,23 @@ func TestHandlerBackendEnvironment(t *testing.T) { | |||
| 295 | 354 | assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks") | |
| 296 | 355 | assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN") | |
| 297 | 356 | assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR") | |
| 298 | - | assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends") | |
| 299 | - | assert.Equal(t, "pub", r.hooks.repo.String()) | |
| 357 | + | assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends") | |
| 358 | + | assert.Equal(t, "pub", r.hookedRepo().String()) | |
| 300 | 359 | ||
| 301 | 360 | // The info/refs advertisement of a push does not register a hook token. | |
| 302 | 361 | r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil) | |
| 303 | - | assert.Equal(t, 1, r.hooks.released) | |
| 362 | + | assert.Equal(t, 1, r.releasedTokens()) | |
| 304 | 363 | } | |
| 305 | 364 | ||
| 306 | 365 | func TestHandlerNormalizesRepositoryPath(t *testing.T) { | |
| 366 | + | t.Parallel() | |
| 307 | 367 | r := newRig(t, nil) | |
| 308 | 368 | _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| 309 | 369 | assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git") | |
| 310 | 370 | } | |
| 311 | 371 | ||
| 312 | 372 | func TestHandlerChunkedBodyIsStreamed(t *testing.T) { | |
| 373 | + | t.Parallel() | |
| 313 | 374 | r := newRig(t, nil) | |
| 314 | 375 | payload := strings.Repeat("x", 300_000) | |
| 315 | 376 | // An io.Reader of unknown length makes the client send chunked. | |
| @@ -325,6 +386,7 @@ func TestHandlerChunkedBodyIsStreamed(t *testing.T) { | |||
| 325 | 386 | } | |
| 326 | 387 | ||
| 327 | 388 | func TestHandlerBodyLimit(t *testing.T) { | |
| 389 | + | t.Parallel() | |
| 328 | 390 | r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 }) | |
| 329 | 391 | big := strings.Repeat("x", 5000) | |
| 330 | 392 | code, _, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader(big), nil) | |
| @@ -342,6 +404,7 @@ func TestHandlerBodyLimit(t *testing.T) { | |||
| 342 | 404 | } | |
| 343 | 405 | ||
| 344 | 406 | func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) { | |
| 407 | + | t.Parallel() | |
| 345 | 408 | r := newRig(t, nil) // the push limit is 4 GiB | |
| 346 | 409 | big := strings.NewReader(strings.Repeat("x", 64<<20+1)) | |
| 347 | 410 | code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil) | |
| @@ -349,6 +412,7 @@ func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) { | |||
| 349 | 412 | } | |
| 350 | 413 | ||
| 351 | 414 | func TestHandlerServiceQuery(t *testing.T) { | |
| 415 | + | t.Parallel() | |
| 352 | 416 | r := newRig(t, nil) | |
| 353 | 417 | for _, q := range []string{ | |
| 354 | 418 | "service=git-upload-pack&service=git-receive-pack", | |
| @@ -366,6 +430,7 @@ func TestHandlerServiceQuery(t *testing.T) { | |||
| 366 | 430 | } | |
| 367 | 431 | ||
| 368 | 432 | func TestHandlerGitProtocolHeader(t *testing.T) { | |
| 433 | + | t.Parallel() | |
| 369 | 434 | r := newRig(t, nil) | |
| 370 | 435 | tests := []struct { | |
| 371 | 436 | value string | |
| @@ -403,6 +468,7 @@ func TestHandlerDoesNotInheritTheProcessEnvironment(t *testing.T) { | |||
| 403 | 468 | } | |
| 404 | 469 | ||
| 405 | 470 | func TestHandlerBackendFailure(t *testing.T) { | |
| 471 | + | t.Parallel() | |
| 406 | 472 | r := newRig(t, nil) | |
| 407 | 473 | r.backendErr = io.ErrUnexpectedEOF | |
| 408 | 474 | code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil) | |
| @@ -411,6 +477,7 @@ func TestHandlerBackendFailure(t *testing.T) { | |||
| 411 | 477 | } | |
| 412 | 478 | ||
| 413 | 479 | func TestHandlerLimiter(t *testing.T) { | |
| 480 | + | t.Parallel() | |
| 414 | 481 | r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) }) | |
| 415 | 482 | for i := range clientlimit.MaxFailures { | |
| 416 | 483 | code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil) | |
| @@ -420,16 +487,13 @@ func TestHandlerLimiter(t *testing.T) { | |||
| 420 | 487 | assert.Equal(t, 429, code) | |
| 421 | 488 | assert.NotEmpty(t, hdr.Get("Retry-After")) | |
| 422 | 489 | // A blocked request does not even reach the authenticator. | |
| 423 | - | r.auth.mu.Lock() | |
| 424 | - | n := len(r.auth.seen) | |
| 425 | - | r.auth.mu.Unlock() | |
| 490 | + | n := r.authenticateCalls() | |
| 426 | 491 | r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil) | |
| 427 | - | r.auth.mu.Lock() | |
| 428 | - | assert.Equal(t, n, len(r.auth.seen)) | |
| 429 | - | r.auth.mu.Unlock() | |
| 492 | + | assert.Equal(t, n, r.authenticateCalls()) | |
| 430 | 493 | } | |
| 431 | 494 | ||
| 432 | 495 | func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |
| 496 | + | t.Parallel() | |
| 433 | 497 | r := newRig(t, func(c *githttp.Config) { | |
| 434 | 498 | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | |
| 435 | 499 | }) | |
| @@ -443,6 +507,7 @@ func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) { | |||
| 443 | 507 | } | |
| 444 | 508 | ||
| 445 | 509 | func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |
| 510 | + | t.Parallel() | |
| 446 | 511 | r := newRig(t, nil) | |
| 447 | 512 | path := "/priv.git/info/refs?service=git-upload-pack" | |
| 448 | 513 | code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"}) | |
| @@ -461,3 +526,35 @@ func TestHandlerBearerAndMalformedAuthorization(t *testing.T) { | |||
| 461 | 526 | _ = resp.Body.Close() | |
| 462 | 527 | assert.Equal(t, 401, resp.StatusCode) | |
| 463 | 528 | } | |
| 529 | + | ||
| 530 | + | func TestHandlerUsesTheFailureLimiter(t *testing.T) { | |
| 531 | + | t.Parallel() | |
| 532 | + | path := "/pub.git/info/refs?service=git-upload-pack" | |
| 533 | + | t.Run("a blocked client is refused before authentication", func(t *testing.T) { | |
| 534 | + | t.Parallel() | |
| 535 | + | limiter := mocks.NewFailureLimiter(t) | |
| 536 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(true, 3*time.Second).Once() | |
| 537 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |
| 538 | + | code, _, hdr := r.do(t, "GET", path, "u", "root-secret", nil, nil) | |
| 539 | + | assert.Equal(t, 429, code) | |
| 540 | + | assert.Equal(t, "3", hdr.Get("Retry-After")) | |
| 541 | + | assert.Zero(t, r.authenticateCalls()) | |
| 542 | + | }) | |
| 543 | + | t.Run("an invalid secret is recorded as a failure", func(t *testing.T) { | |
| 544 | + | t.Parallel() | |
| 545 | + | limiter := mocks.NewFailureLimiter(t) | |
| 546 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once() | |
| 547 | + | limiter.EXPECT().Fail(mock.Anything, "127.0.0.1").Once() | |
| 548 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |
| 549 | + | code, _, _ := r.do(t, "GET", path, "u", "wrong", nil, nil) | |
| 550 | + | assert.Equal(t, 401, code) | |
| 551 | + | }) | |
| 552 | + | t.Run("a valid secret is not a failure", func(t *testing.T) { | |
| 553 | + | t.Parallel() | |
| 554 | + | limiter := mocks.NewFailureLimiter(t) | |
| 555 | + | limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once() | |
| 556 | + | r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter }) | |
| 557 | + | code, _, _ := r.do(t, "GET", path, "u", "root-secret", nil, nil) | |
| 558 | + | assert.Equal(t, 200, code) | |
| 559 | + | }) | |
| 560 | + | } | |
M internal/adapters/githttp/hooks.go +6 -12
| @@ -19,17 +19,9 @@ import ( | |||
| 19 | 19 | "github.com/bnema/zerowrap" | |
| 20 | 20 | ||
| 21 | 21 | "bnema.dev/ermite/internal/domain" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | - | // PushHooks is what the hook socket asks of the use cases. app.Pushes fits. | |
| 25 | - | type PushHooks interface { | |
| 26 | - | // Check decides a push before Git applies it; an error matching | |
| 27 | - | // domain.ErrRejected refuses it, any other error refuses it as a failure. | |
| 28 | - | Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |
| 29 | - | // Record handles an accepted push. | |
| 30 | - | Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |
| 31 | - | } | |
| 32 | - | ||
| 33 | 25 | // readTimeout bounds how long a connection may take to send its request line. | |
| 34 | 26 | const readTimeout = 30 * time.Second | |
| 35 | 27 | ||
| @@ -46,7 +38,7 @@ type hookEntry struct { | |||
| 46 | 38 | // socket the hooks call back on. It implements HookTokens. | |
| 47 | 39 | type HookServer struct { | |
| 48 | 40 | path string | |
| 49 | - | hooks PushHooks | |
| 41 | + | hooks ports.PushHookService | |
| 50 | 42 | ||
| 51 | 43 | mu sync.RWMutex | |
| 52 | 44 | tokens map[string]hookEntry | |
| @@ -57,10 +49,10 @@ type HookServer struct { | |||
| 57 | 49 | wg sync.WaitGroup | |
| 58 | 50 | } | |
| 59 | 51 | ||
| 60 | - | var _ HookTokens = (*HookServer)(nil) | |
| 52 | + | var _ ports.HookTokens = (*HookServer)(nil) | |
| 61 | 53 | ||
| 62 | 54 | // NewHookServer builds a server for the socket at path. Call Serve to listen. | |
| 63 | - | func NewHookServer(path string, hooks PushHooks) *HookServer { | |
| 55 | + | func NewHookServer(path string, hooks ports.PushHookService) *HookServer { | |
| 64 | 56 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | |
| 65 | 57 | } | |
| 66 | 58 | ||
| @@ -183,6 +175,8 @@ func (s *HookServer) Close(ctx context.Context) { | |||
| 183 | 175 | } | |
| 184 | 176 | ||
| 185 | 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' | |
| 186 | 180 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |
| 187 | 181 | log := zerowrap.FromCtx(ctx) | |
| 188 | 182 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | |
M internal/adapters/githttp/hooks_test.go +60 -92
| @@ -18,10 +18,13 @@ import ( | |||
| 18 | 18 | "time" | |
| 19 | 19 | ||
| 20 | 20 | "github.com/stretchr/testify/assert" | |
| 21 | + | "github.com/stretchr/testify/mock" | |
| 21 | 22 | "github.com/stretchr/testify/require" | |
| 22 | 23 | ||
| 23 | 24 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 24 | 25 | "bnema.dev/ermite/internal/domain" | |
| 26 | + | "bnema.dev/ermite/internal/ports" | |
| 27 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 25 | 28 | ) | |
| 26 | 29 | ||
| 27 | 30 | const ( | |
| @@ -29,46 +32,6 @@ const ( | |||
| 29 | 32 | oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" | |
| 30 | 33 | ) | |
| 31 | 34 | ||
| 32 | - | type call struct { | |
| 33 | - | hook string | |
| 34 | - | p domain.Principal | |
| 35 | - | repo domain.RepoName | |
| 36 | - | updates []domain.RefUpdate | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | type fakeHooks struct { | |
| 40 | - | mu sync.Mutex | |
| 41 | - | calls []call | |
| 42 | - | checkErr error | |
| 43 | - | recErr error | |
| 44 | - | // onRecord, when set, runs inside Record with its context. | |
| 45 | - | onRecord func(ctx context.Context) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |
| 49 | - | f.mu.Lock() | |
| 50 | - | defer f.mu.Unlock() | |
| 51 | - | f.calls = append(f.calls, call{"check", p, n, u}) | |
| 52 | - | return f.checkErr | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error { | |
| 56 | - | f.mu.Lock() | |
| 57 | - | f.calls = append(f.calls, call{"record", p, n, u}) | |
| 58 | - | err, on := f.recErr, f.onRecord | |
| 59 | - | f.mu.Unlock() | |
| 60 | - | if on != nil { | |
| 61 | - | on(ctx) | |
| 62 | - | } | |
| 63 | - | return err | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func (f *fakeHooks) last() call { | |
| 67 | - | f.mu.Lock() | |
| 68 | - | defer f.mu.Unlock() | |
| 69 | - | return f.calls[len(f.calls)-1] | |
| 70 | - | } | |
| 71 | - | ||
| 72 | 35 | func repoName(t *testing.T, s string) domain.RepoName { | |
| 73 | 36 | t.Helper() | |
| 74 | 37 | n, err := domain.ParseRepoName(s) | |
| @@ -77,7 +40,7 @@ func repoName(t *testing.T, s string) domain.RepoName { | |||
| 77 | 40 | } | |
| 78 | 41 | ||
| 79 | 42 | // startHooks serves a hook socket in a short temporary directory. | |
| 80 | - | func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) { | |
| 43 | + | func startHooks(t *testing.T, h ports.PushHookService) (*githttp.HookServer, string) { | |
| 81 | 44 | t.Helper() | |
| 82 | 45 | dir, err := os.MkdirTemp("", "hk") | |
| 83 | 46 | require.NoError(t, err) | |
| @@ -117,7 +80,8 @@ func raw(t *testing.T, sock, line string) githttp.HookReply { | |||
| 117 | 80 | } | |
| 118 | 81 | ||
| 119 | 82 | func TestHookSocketMode(t *testing.T) { | |
| 120 | - | _, sock := startHooks(t, &fakeHooks{}) | |
| 83 | + | t.Parallel() | |
| 84 | + | _, sock := startHooks(t, mocks.NewPushHookService(t)) | |
| 121 | 85 | fi, err := os.Stat(sock) | |
| 122 | 86 | require.NoError(t, err) | |
| 123 | 87 | assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm()) | |
| @@ -125,26 +89,28 @@ func TestHookSocketMode(t *testing.T) { | |||
| 125 | 89 | } | |
| 126 | 90 | ||
| 127 | 91 | func TestHookServerReplacesStaleSocketFile(t *testing.T) { | |
| 92 | + | t.Parallel() | |
| 128 | 93 | dir, err := os.MkdirTemp("", "hk") | |
| 129 | 94 | require.NoError(t, err) | |
| 130 | 95 | defer func() { _ = os.RemoveAll(dir) }() | |
| 131 | 96 | sock := filepath.Join(dir, "hook.sock") | |
| 132 | 97 | require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600)) | |
| 133 | - | s := githttp.NewHookServer(sock, &fakeHooks{}) | |
| 98 | + | s := githttp.NewHookServer(sock, mocks.NewPushHookService(t)) | |
| 134 | 99 | require.NoError(t, s.Listen(context.Background())) | |
| 135 | 100 | s.Close(context.Background()) | |
| 136 | 101 | } | |
| 137 | 102 | ||
| 138 | 103 | func TestHookServerRejectsTooLongSocketPath(t *testing.T) { | |
| 139 | - | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{}) | |
| 104 | + | t.Parallel() | |
| 105 | + | s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", mocks.NewPushHookService(t)) | |
| 140 | 106 | err := s.Listen(context.Background()) | |
| 141 | 107 | require.Error(t, err) | |
| 142 | 108 | assert.Contains(t, err.Error(), "too long") | |
| 143 | 109 | } | |
| 144 | 110 | ||
| 145 | 111 | func TestHookProtocol(t *testing.T) { | |
| 146 | - | fh := &fakeHooks{} | |
| 147 | - | srv, sock := startHooks(t, fh) | |
| 112 | + | hooks := mocks.NewPushHookService(t) | |
| 113 | + | srv, sock := startHooks(t, hooks) | |
| 148 | 114 | p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |
| 149 | 115 | tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p) | |
| 150 | 116 | tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root()) | |
| @@ -154,46 +120,42 @@ func TestHookProtocol(t *testing.T) { | |||
| 154 | 120 | upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]` | |
| 155 | 121 | ||
| 156 | 122 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | |
| 123 | + | updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |
| 124 | + | hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }), | |
| 125 | + | repoName(t, "alpha"), updates).Return(nil).Once() | |
| 157 | 126 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 158 | 127 | assert.True(t, r.OK) | |
| 159 | - | c := fh.last() | |
| 160 | - | assert.Equal(t, "check", c.hook) | |
| 161 | - | assert.Equal(t, repoName(t, "alpha"), c.repo) | |
| 162 | - | assert.Equal(t, "tok1", c.p.TokenID()) | |
| 163 | - | assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates) | |
| 164 | 128 | ||
| 129 | + | hooks.EXPECT().Record(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.IsRoot() }), | |
| 130 | + | repoName(t, "beta"), updates).Return(nil).Once() | |
| 165 | 131 | r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`) | |
| 166 | 132 | assert.True(t, r.OK) | |
| 167 | - | c = fh.last() | |
| 168 | - | assert.Equal(t, "record", c.hook) | |
| 169 | - | assert.Equal(t, repoName(t, "beta"), c.repo) | |
| 170 | - | assert.True(t, c.p.IsRoot()) | |
| 171 | 133 | }) | |
| 172 | 134 | ||
| 173 | 135 | t.Run("a repository sent by the client is ignored", func(t *testing.T) { | |
| 174 | - | raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | |
| 175 | - | assert.Equal(t, repoName(t, "alpha"), fh.last().repo) | |
| 136 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once() | |
| 137 | + | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | |
| 138 | + | assert.True(t, r.OK) | |
| 176 | 139 | }) | |
| 177 | 140 | ||
| 178 | 141 | t.Run("refusal carries the message", func(t *testing.T) { | |
| 179 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch")) | |
| 180 | - | defer func() { fh.checkErr = nil }() | |
| 142 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 143 | + | Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once() | |
| 181 | 144 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 182 | 145 | assert.False(t, r.OK) | |
| 183 | 146 | assert.Contains(t, r.Message, "rejected") | |
| 184 | 147 | }) | |
| 185 | 148 | ||
| 186 | 149 | t.Run("an internal error fails closed without leaking details", func(t *testing.T) { | |
| 187 | - | fh.checkErr = errors.New("disk /secret/path exploded") | |
| 188 | - | defer func() { fh.checkErr = nil }() | |
| 150 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 151 | + | Return(errors.New("disk /secret/path exploded")).Once() | |
| 189 | 152 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 190 | 153 | assert.False(t, r.OK) | |
| 191 | 154 | assert.NotContains(t, r.Message, "/secret/path") | |
| 192 | 155 | }) | |
| 193 | 156 | ||
| 194 | 157 | t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) { | |
| 195 | - | fh.recErr = errors.New("boom") | |
| 196 | - | defer func() { fh.recErr = nil }() | |
| 158 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(errors.New("boom")).Once() | |
| 197 | 159 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`) | |
| 198 | 160 | assert.True(t, r.OK) | |
| 199 | 161 | assert.NotEmpty(t, r.Message) | |
| @@ -205,18 +167,16 @@ func TestHookProtocol(t *testing.T) { | |||
| 205 | 167 | assert.False(t, r.OK, tok) | |
| 206 | 168 | } | |
| 207 | 169 | releaseA() | |
| 208 | - | before := len(fh.calls) | |
| 209 | 170 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`) | |
| 210 | 171 | assert.False(t, r.OK, "released token") | |
| 211 | 172 | assert.Contains(t, r.Message, "token") | |
| 212 | - | assert.Len(t, fh.calls, before, "no use case ran") | |
| 173 | + | // The hooks mock expects no call: no use case ran. | |
| 213 | 174 | releaseB() | |
| 214 | 175 | }) | |
| 215 | 176 | ||
| 216 | 177 | t.Run("malformed requests are refused", func(t *testing.T) { | |
| 217 | 178 | tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root()) | |
| 218 | 179 | defer rel() | |
| 219 | - | before := len(fh.calls) | |
| 220 | 180 | for _, line := range []string{ | |
| 221 | 181 | `not json`, | |
| 222 | 182 | `{}`, | |
| @@ -227,7 +187,7 @@ func TestHookProtocol(t *testing.T) { | |||
| 227 | 187 | r := raw(t, sock, line) | |
| 228 | 188 | assert.False(t, r.OK, line) | |
| 229 | 189 | } | |
| 230 | - | assert.Len(t, fh.calls, before) | |
| 190 | + | // The hooks mock expects no call: no use case ran. | |
| 231 | 191 | }) | |
| 232 | 192 | ||
| 233 | 193 | t.Run("a connection that closes without a request does not hurt", func(t *testing.T) { | |
| @@ -242,24 +202,27 @@ func TestHookProtocol(t *testing.T) { | |||
| 242 | 202 | // A shutdown while a push is being recorded must not drop the recording: the | |
| 243 | 203 | // push is already in the repository. | |
| 244 | 204 | func TestHookRecordSurvivesShutdown(t *testing.T) { | |
| 245 | - | fh := &fakeHooks{} | |
| 205 | + | t.Parallel() | |
| 206 | + | hooks := mocks.NewPushHookService(t) | |
| 246 | 207 | started, proceed := make(chan struct{}), make(chan struct{}) | |
| 247 | 208 | var cancelled, hasDeadline bool | |
| 248 | 209 | var ctxMu sync.Mutex | |
| 249 | - | fh.onRecord = func(ctx context.Context) { | |
| 250 | - | close(started) | |
| 251 | - | <-proceed | |
| 252 | - | ctxMu.Lock() | |
| 253 | - | defer ctxMu.Unlock() | |
| 254 | - | cancelled = ctx.Err() != nil | |
| 255 | - | _, hasDeadline = ctx.Deadline() | |
| 256 | - | } | |
| 210 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 211 | + | func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ []domain.RefUpdate) error { | |
| 212 | + | close(started) | |
| 213 | + | <-proceed | |
| 214 | + | ctxMu.Lock() | |
| 215 | + | defer ctxMu.Unlock() | |
| 216 | + | cancelled = ctx.Err() != nil | |
| 217 | + | _, hasDeadline = ctx.Deadline() | |
| 218 | + | return nil | |
| 219 | + | }).Once() | |
| 257 | 220 | ||
| 258 | 221 | dir, err := os.MkdirTemp("", "hk") | |
| 259 | 222 | require.NoError(t, err) | |
| 260 | 223 | t.Cleanup(func() { _ = os.RemoveAll(dir) }) | |
| 261 | 224 | sock := filepath.Join(dir, "hook.sock") | |
| 262 | - | srv := githttp.NewHookServer(sock, fh) | |
| 225 | + | srv := githttp.NewHookServer(sock, hooks) | |
| 263 | 226 | require.NoError(t, srv.Listen(context.Background())) | |
| 264 | 227 | ctx, cancel := context.WithCancel(context.Background()) | |
| 265 | 228 | done := make(chan error, 1) | |
| @@ -294,9 +257,14 @@ func TestHookRecordSurvivesShutdown(t *testing.T) { | |||
| 294 | 257 | } | |
| 295 | 258 | ||
| 296 | 259 | func TestHookConcurrentTokens(t *testing.T) { | |
| 297 | - | fh := &fakeHooks{} | |
| 298 | - | srv, sock := startHooks(t, fh) | |
| 260 | + | t.Parallel() | |
| 261 | + | hooks := mocks.NewPushHookService(t) | |
| 262 | + | srv, sock := startHooks(t, hooks) | |
| 299 | 263 | var wg sync.WaitGroup | |
| 264 | + | // Each of the 16 tokens reaches the use case for its own repository, once. | |
| 265 | + | for i := range 16 { | |
| 266 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "r"+string(rune('a'+i))), mock.Anything).Return(nil).Once() | |
| 267 | + | } | |
| 300 | 268 | for i := range 16 { | |
| 301 | 269 | wg.Add(1) | |
| 302 | 270 | go func() { | |
| @@ -309,39 +277,35 @@ func TestHookConcurrentTokens(t *testing.T) { | |||
| 309 | 277 | }() | |
| 310 | 278 | } | |
| 311 | 279 | wg.Wait() | |
| 312 | - | seen := map[domain.RepoName]bool{} | |
| 313 | - | for _, c := range fh.calls { | |
| 314 | - | seen[c.repo] = true | |
| 315 | - | } | |
| 316 | - | assert.Len(t, seen, 16) | |
| 317 | 280 | } | |
| 318 | 281 | ||
| 319 | 282 | func TestRunHook(t *testing.T) { | |
| 320 | 283 | ctx := context.Background() | |
| 321 | - | fh := &fakeHooks{} | |
| 322 | - | srv, sock := startHooks(t, fh) | |
| 284 | + | hooks := mocks.NewPushHookService(t) | |
| 285 | + | srv, sock := startHooks(t, hooks) | |
| 323 | 286 | tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root()) | |
| 324 | 287 | defer rel() | |
| 325 | 288 | input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n" | |
| 326 | 289 | ||
| 327 | 290 | t.Run("sends the lines git feeds the hook", func(t *testing.T) { | |
| 328 | 291 | var errOut bytes.Buffer | |
| 329 | - | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |
| 330 | - | assert.Equal(t, []domain.RefUpdate{ | |
| 292 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{ | |
| 331 | 293 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | |
| 332 | 294 | {Old: oidA, New: oidB, Ref: "refs/heads/dev"}, | |
| 333 | - | }, fh.last().updates) | |
| 295 | + | }).Return(nil).Once() | |
| 296 | + | require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)) | |
| 334 | 297 | assert.Empty(t, errOut.String()) | |
| 335 | 298 | }) | |
| 336 | 299 | ||
| 337 | 300 | t.Run("a ref name with spaces survives", func(t *testing.T) { | |
| 301 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"), | |
| 302 | + | []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once() | |
| 338 | 303 | require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok)) | |
| 339 | - | assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref) | |
| 340 | 304 | }) | |
| 341 | 305 | ||
| 342 | 306 | t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) { | |
| 343 | - | fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope")) | |
| 344 | - | defer func() { fh.checkErr = nil }() | |
| 307 | + | hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 308 | + | Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once() | |
| 345 | 309 | var errOut bytes.Buffer | |
| 346 | 310 | err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok) | |
| 347 | 311 | require.ErrorIs(t, err, githttp.ErrHookRejected) | |
| @@ -400,6 +364,7 @@ func TestRunHook(t *testing.T) { | |||
| 400 | 364 | } | |
| 401 | 365 | ||
| 402 | 366 | func TestWriteHookScripts(t *testing.T) { | |
| 367 | + | t.Parallel() | |
| 403 | 368 | dir := filepath.Join(t.TempDir(), "run", "hooks") | |
| 404 | 369 | exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite" | |
| 405 | 370 | require.NoError(t, githttp.WriteHookScripts(dir, exe)) | |
| @@ -425,6 +390,9 @@ func TestWriteHookScripts(t *testing.T) { | |||
| 425 | 390 | } | |
| 426 | 391 | ||
| 427 | 392 | // The shell really passes the awkward path and the arguments through. | |
| 393 | + | // | |
| 394 | + | // Not parallel: it writes executables, and a test that forks meanwhile would | |
| 395 | + | // inherit the write descriptor and make exec fail with ETXTBSY. | |
| 428 | 396 | func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | |
| 429 | 397 | base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z") | |
| 430 | 398 | require.NoError(t, os.MkdirAll(base, 0o755)) | |
A internal/adapters/githttp/limits_test.go +287 -0
| @@ -0,0 +1,287 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package githttp_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/netip" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "testing" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite/internal/adapters/githttp" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | pathUpload = "/pub.git/git-upload-pack" | |
| 27 | + | pathRefs = "/pub.git/info/refs?service=git-upload-pack" | |
| 28 | + | pathReceive = "/pub.git/git-receive-pack" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // held is a request kept inside the backend until the gate opens. | |
| 32 | + | type held struct{ path, client string } | |
| 33 | + | ||
| 34 | + | // gatedRig is a rig whose backend blocks every request that reaches it until | |
| 35 | + | // open is called, so a test decides exactly which slots are taken. | |
| 36 | + | type gatedRig struct { | |
| 37 | + | *rig | |
| 38 | + | entered chan struct{} // a request reached the backend | |
| 39 | + | served chan struct{} // a request left the handler, its slots released | |
| 40 | + | gate chan struct{} | |
| 41 | + | done sync.WaitGroup | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func newGatedRig(t *testing.T, mut func(*githttp.Config)) *gatedRig { | |
| 45 | + | t.Helper() | |
| 46 | + | g := &gatedRig{entered: make(chan struct{}, 64), served: make(chan struct{}, 64), gate: make(chan struct{})} | |
| 47 | + | script := cgiScript(t) | |
| 48 | + | be := mocks.NewGitBackend(t) | |
| 49 | + | be.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn( | |
| 50 | + | func(ctx context.Context, _ bool) (ports.HTTPBackend, error) { | |
| 51 | + | g.entered <- struct{}{} | |
| 52 | + | select { | |
| 53 | + | case <-g.gate: | |
| 54 | + | case <-ctx.Done(): | |
| 55 | + | } | |
| 56 | + | return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin"}}, nil | |
| 57 | + | }).Maybe() | |
| 58 | + | g.rig = newRig(t, func(c *githttp.Config) { | |
| 59 | + | c.Backend = be | |
| 60 | + | c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")} | |
| 61 | + | if mut != nil { | |
| 62 | + | mut(c) | |
| 63 | + | } | |
| 64 | + | }) | |
| 65 | + | // A client sees the response before the handler returns and gives its | |
| 66 | + | // slots back: serve through a wrapper that tells when it has. | |
| 67 | + | g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 68 | + | defer func() { g.served <- struct{}{} }() | |
| 69 | + | g.h.ServeHTTP(w, r) | |
| 70 | + | })) | |
| 71 | + | t.Cleanup(g.srv.Close) | |
| 72 | + | t.Cleanup(func() { g.open(); g.done.Wait() }) | |
| 73 | + | return g | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // waitServed waits until n requests have left the handler. | |
| 77 | + | func (g *gatedRig) waitServed(n int) { | |
| 78 | + | for range n { | |
| 79 | + | <-g.served | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // hold starts the requests and returns once all of them are inside the backend. | |
| 84 | + | func (g *gatedRig) hold(t *testing.T, reqs []held) { | |
| 85 | + | t.Helper() | |
| 86 | + | for _, h := range reqs { | |
| 87 | + | method := http.MethodPost | |
| 88 | + | if h.path == pathRefs { | |
| 89 | + | method = http.MethodGet | |
| 90 | + | } | |
| 91 | + | g.done.Go(func() { | |
| 92 | + | req, err := http.NewRequest(method, g.srv.URL+h.path, nil) | |
| 93 | + | if err != nil { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | req.SetBasicAuth("u", "root-secret") | |
| 97 | + | req.Header.Set("X-Forwarded-For", h.client) | |
| 98 | + | if resp, err := http.DefaultClient.Do(req); err == nil { | |
| 99 | + | _ = resp.Body.Close() | |
| 100 | + | } | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | for range reqs { | |
| 104 | + | <-g.entered | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (g *gatedRig) open() { | |
| 109 | + | select { | |
| 110 | + | case <-g.gate: | |
| 111 | + | default: | |
| 112 | + | close(g.gate) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestHandlerBackendLimits(t *testing.T) { | |
| 117 | + | t.Parallel() | |
| 118 | + | tests := []struct { | |
| 119 | + | name string | |
| 120 | + | cfg func(*githttp.Config) | |
| 121 | + | hold []held | |
| 122 | + | probe held | |
| 123 | + | want int | |
| 124 | + | }{ | |
| 125 | + | { | |
| 126 | + | name: "free upload slot", | |
| 127 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 2 }, | |
| 128 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |
| 129 | + | probe: held{pathUpload, "198.51.100.2"}, | |
| 130 | + | want: http.StatusOK, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "upload slots full", | |
| 134 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 2 }, | |
| 135 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathRefs, "198.51.100.2"}}, | |
| 136 | + | probe: held{pathUpload, "198.51.100.3"}, | |
| 137 | + | want: http.StatusServiceUnavailable, | |
| 138 | + | }, | |
| 139 | + | { | |
| 140 | + | name: "ref advertisement counts as an upload", | |
| 141 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1 }, | |
| 142 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |
| 143 | + | probe: held{pathRefs, "198.51.100.2"}, | |
| 144 | + | want: http.StatusServiceUnavailable, | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | name: "receive slots full", | |
| 148 | + | cfg: func(c *githttp.Config) { c.MaxReceivePacks = 1 }, | |
| 149 | + | hold: []held{{pathReceive, "198.51.100.1"}}, | |
| 150 | + | probe: held{pathReceive, "198.51.100.2"}, | |
| 151 | + | want: http.StatusServiceUnavailable, | |
| 152 | + | }, | |
| 153 | + | { | |
| 154 | + | name: "pushes do not take upload slots", | |
| 155 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 }, | |
| 156 | + | hold: []held{{pathUpload, "198.51.100.1"}}, | |
| 157 | + | probe: held{pathReceive, "198.51.100.2"}, | |
| 158 | + | want: http.StatusOK, | |
| 159 | + | }, | |
| 160 | + | { | |
| 161 | + | name: "fetches do not take receive slots", | |
| 162 | + | cfg: func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 }, | |
| 163 | + | hold: []held{{pathReceive, "198.51.100.1"}}, | |
| 164 | + | probe: held{pathUpload, "198.51.100.2"}, | |
| 165 | + | want: http.StatusOK, | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "client at its cap", | |
| 169 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 2 }, | |
| 170 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathReceive, "198.51.100.1"}}, | |
| 171 | + | probe: held{pathUpload, "198.51.100.1"}, | |
| 172 | + | want: http.StatusTooManyRequests, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "another client is not affected by the cap", | |
| 176 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 2 }, | |
| 177 | + | hold: []held{{pathUpload, "198.51.100.1"}, {pathUpload, "198.51.100.1"}}, | |
| 178 | + | probe: held{pathUpload, "198.51.100.2"}, | |
| 179 | + | want: http.StatusOK, | |
| 180 | + | }, | |
| 181 | + | { | |
| 182 | + | name: "addresses of one IPv6 /64 are one client", | |
| 183 | + | cfg: func(c *githttp.Config) { c.MaxPerClient = 1 }, | |
| 184 | + | hold: []held{{pathUpload, "2001:db8::1"}}, | |
| 185 | + | probe: held{pathUpload, "2001:db8::2"}, | |
| 186 | + | want: http.StatusTooManyRequests, | |
| 187 | + | }, | |
| 188 | + | } | |
| 189 | + | for _, tt := range tests { | |
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |
| 191 | + | t.Parallel() | |
| 192 | + | g := newGatedRig(t, func(c *githttp.Config) { | |
| 193 | + | c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 8, 8, 8 | |
| 194 | + | tt.cfg(c) | |
| 195 | + | }) | |
| 196 | + | g.hold(t, tt.hold) | |
| 197 | + | if tt.want == http.StatusOK { | |
| 198 | + | g.open() // the probe has to get through the backend gate too | |
| 199 | + | } | |
| 200 | + | code, _, hdr := g.do(t, http.MethodPost, tt.probe.path, "u", "root-secret", nil, | |
| 201 | + | map[string]string{"X-Forwarded-For": tt.probe.client}) | |
| 202 | + | if tt.probe.path == pathRefs { | |
| 203 | + | code, _, hdr = g.do(t, http.MethodGet, tt.probe.path, "u", "root-secret", nil, | |
| 204 | + | map[string]string{"X-Forwarded-For": tt.probe.client}) | |
| 205 | + | } | |
| 206 | + | assert.Equal(t, tt.want, code) | |
| 207 | + | if tt.want != http.StatusOK { | |
| 208 | + | assert.NotEmpty(t, hdr.Get("Retry-After")) | |
| 209 | + | } | |
| 210 | + | }) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func TestHandlerBackendSlotsAreReleased(t *testing.T) { | |
| 215 | + | t.Parallel() | |
| 216 | + | g := newGatedRig(t, func(c *githttp.Config) { | |
| 217 | + | c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 1, 1, 1 | |
| 218 | + | }) | |
| 219 | + | hdr := map[string]string{"X-Forwarded-For": "198.51.100.1"} | |
| 220 | + | ||
| 221 | + | g.hold(t, []held{{pathUpload, "198.51.100.1"}}) | |
| 222 | + | code, _, _ := g.do(t, http.MethodPost, pathUpload, "u", "root-secret", nil, hdr) | |
| 223 | + | require.Equal(t, http.StatusTooManyRequests, code) | |
| 224 | + | g.waitServed(1) | |
| 225 | + | ||
| 226 | + | g.open() | |
| 227 | + | g.waitServed(1) | |
| 228 | + | for _, p := range []string{pathUpload, pathReceive, pathUpload} { | |
| 229 | + | code, _, _ = g.do(t, http.MethodPost, p, "u", "root-secret", nil, hdr) | |
| 230 | + | assert.Equal(t, http.StatusOK, code, "the slots came back: %s", p) | |
| 231 | + | g.waitServed(1) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func TestHandlerBackendFailureReleasesSlots(t *testing.T) { | |
| 236 | + | t.Parallel() | |
| 237 | + | r := newRig(t, func(c *githttp.Config) { c.MaxUploadPacks, c.MaxPerClient = 1, 1 }) | |
| 238 | + | r.backendErr = context.DeadlineExceeded | |
| 239 | + | for range 3 { | |
| 240 | + | code, _, _ := r.do(t, http.MethodPost, pathUpload, "", "", nil, nil) | |
| 241 | + | assert.Equal(t, http.StatusInternalServerError, code, "never a refusal: the slot is released on failure") | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // deadlineRecorder is a ResponseWriter that supports the read deadline, as | |
| 246 | + | // the writer of net/http does, and records it. | |
| 247 | + | type deadlineRecorder struct { | |
| 248 | + | *httptest.ResponseRecorder | |
| 249 | + | deadlines []time.Time | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | func (d *deadlineRecorder) SetReadDeadline(t time.Time) error { | |
| 253 | + | d.deadlines = append(d.deadlines, t) | |
| 254 | + | return nil | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func TestHandlerBodyReadDeadline(t *testing.T) { | |
| 258 | + | t.Parallel() | |
| 259 | + | tests := []struct { | |
| 260 | + | name string | |
| 261 | + | path string | |
| 262 | + | max time.Duration | |
| 263 | + | min time.Duration | |
| 264 | + | }{ | |
| 265 | + | {"fetch body", pathUpload, time.Minute, 30 * time.Second}, | |
| 266 | + | {"push body is bounded by idleness", pathReceive, 5 * time.Minute, time.Minute}, | |
| 267 | + | } | |
| 268 | + | for _, tt := range tests { | |
| 269 | + | t.Run(tt.name, func(t *testing.T) { | |
| 270 | + | t.Parallel() | |
| 271 | + | r := newRig(t, nil) | |
| 272 | + | req := httptest.NewRequest(http.MethodPost, tt.path, stringsReader("payload")) | |
| 273 | + | req.SetBasicAuth("u", "root-secret") | |
| 274 | + | w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} | |
| 275 | + | start := time.Now() | |
| 276 | + | r.h.ServeHTTP(w, req) | |
| 277 | + | require.Equal(t, http.StatusOK, w.Code) | |
| 278 | + | ||
| 279 | + | require.GreaterOrEqual(t, len(w.deadlines), 2) | |
| 280 | + | first, last := w.deadlines[0], w.deadlines[len(w.deadlines)-1] | |
| 281 | + | assert.WithinRange(t, first, start.Add(tt.min), time.Now().Add(tt.max)) | |
| 282 | + | assert.True(t, last.IsZero(), "the deadline is lifted once the body is read") | |
| 283 | + | }) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func stringsReader(s string) *strings.Reader { return strings.NewReader(s) } | |
M internal/adapters/httpserver/health.go +47 -8
| @@ -3,26 +3,65 @@ | |||
| 3 | 3 | package httpserver | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 6 | 7 | "net/http" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 7 | 10 | ||
| 8 | 11 | "github.com/bnema/zerowrap" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 14 | + | // healthCacheTTL is how long the result of the checks is reused. The endpoint | |
| 15 | + | // is unauthenticated and the checks touch the disk and the store: a burst of | |
| 16 | + | // requests must not turn into a burst of work. | |
| 17 | + | const healthCacheTTL = 5 * time.Second | |
| 18 | + | ||
| 19 | + | // healthCheckTimeout bounds one run of the checks. | |
| 20 | + | const healthCheckTimeout = 5 * time.Second | |
| 21 | + | ||
| 11 | 22 | // healthHandler answers 200 "ok" when every check passes and 503 otherwise. | |
| 12 | 23 | // The failure is logged, never sent: the body stays the same for any cause. | |
| 24 | + | // The result, failure included, is kept for healthCacheTTL; concurrent | |
| 25 | + | // requests wait for the run in progress rather than starting their own. | |
| 13 | 26 | func healthHandler(checks []Check) http.HandlerFunc { | |
| 27 | + | var ( | |
| 28 | + | mu sync.Mutex | |
| 29 | + | until time.Time // the cached result is valid before this instant | |
| 30 | + | healthy bool | |
| 31 | + | ) | |
| 14 | 32 | return func(w http.ResponseWriter, r *http.Request) { | |
| 33 | + | mu.Lock() | |
| 34 | + | if !time.Now().Before(until) { | |
| 35 | + | // The run belongs to every waiting request, not to this one: a | |
| 36 | + | // client that goes away must not cache a failure. | |
| 37 | + | ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), healthCheckTimeout) | |
| 38 | + | healthy = runChecks(ctx, checks) | |
| 39 | + | cancel() | |
| 40 | + | until = time.Now().Add(healthCacheTTL) | |
| 41 | + | } | |
| 42 | + | ok := healthy | |
| 43 | + | mu.Unlock() | |
| 44 | + | ||
| 15 | 45 | w.Header().Set("Cache-Control", "no-store") | |
| 16 | 46 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 17 | - | for _, check := range checks { | |
| 18 | - | if err := check(r.Context()); err != nil { | |
| 19 | - | log := zerowrap.FromCtx(r.Context()) | |
| 20 | - | log.Warn().Err(err).Msg("health check failed") | |
| 21 | - | w.WriteHeader(http.StatusServiceUnavailable) | |
| 22 | - | _, _ = w.Write([]byte("unavailable\n")) | |
| 23 | - | return | |
| 24 | - | } | |
| 47 | + | if !ok { | |
| 48 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 49 | + | _, _ = w.Write([]byte("unavailable\n")) | |
| 50 | + | return | |
| 25 | 51 | } | |
| 26 | 52 | _, _ = w.Write([]byte("ok\n")) | |
| 27 | 53 | } | |
| 28 | 54 | } | |
| 55 | + | ||
| 56 | + | // runChecks runs the checks in order and stops at the first failure, which it | |
| 57 | + | // logs. | |
| 58 | + | func runChecks(ctx context.Context, checks []Check) bool { | |
| 59 | + | for _, check := range checks { | |
| 60 | + | if err := check(ctx); err != nil { | |
| 61 | + | log := zerowrap.FromCtx(ctx) | |
| 62 | + | log.Warn().Err(err).Msg("health check failed") | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return true | |
| 67 | + | } | |
M internal/adapters/httpserver/middleware.go +20 -4
| @@ -4,6 +4,7 @@ package httpserver | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "net/http" | |
| 7 | + | "net/url" | |
| 7 | 8 | "path" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -12,18 +13,33 @@ import ( | |||
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | |
| 15 | - | // for htmx attribute expressions; inline scripts stay forbidden. | |
| 16 | - | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 16 | + | // for htmx attribute expressions; inline scripts stay forbidden. Forms post | |
| 17 | + | // to the origin only, <base> and plugins are off. | |
| 18 | + | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" | |
| 19 | + | ||
| 20 | + | // hstsValue is one year. No includeSubDomains or preload: the server cannot | |
| 21 | + | // speak for the rest of the domain. | |
| 22 | + | const hstsValue = "max-age=31536000" | |
| 23 | + | ||
| 24 | + | // isHTTPS reports whether the public URL is an https:// URL. | |
| 25 | + | func isHTTPS(publicURL string) bool { | |
| 26 | + | u, err := url.Parse(publicURL) | |
| 27 | + | return err == nil && strings.EqualFold(u.Scheme, "https") | |
| 28 | + | } | |
| 17 | 29 | ||
| 18 | 30 | // withSecurityHeaders sets the security headers on every response, before | |
| 19 | - | // the handler runs, so that errors and 404s carry them too. | |
| 20 | - | func withSecurityHeaders(next http.Handler) http.Handler { | |
| 31 | + | // the handler runs, so that errors and 404s carry them too. With hsts, the | |
| 32 | + | // browser is told to stay on https. | |
| 33 | + | func withSecurityHeaders(next http.Handler, hsts bool) http.Handler { | |
| 21 | 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 22 | 35 | h := w.Header() | |
| 23 | 36 | h.Set("Content-Security-Policy", contentSecurityPolicy) | |
| 24 | 37 | h.Set("X-Frame-Options", "DENY") | |
| 25 | 38 | h.Set("X-Content-Type-Options", "nosniff") | |
| 26 | 39 | h.Set("Referrer-Policy", "same-origin") | |
| 40 | + | if hsts { | |
| 41 | + | h.Set("Strict-Transport-Security", hstsValue) | |
| 42 | + | } | |
| 27 | 43 | next.ServeHTTP(w, r) | |
| 28 | 44 | }) | |
| 29 | 45 | } | |
M internal/adapters/httpserver/server.go +7 -2
| @@ -36,7 +36,12 @@ type Route struct { | |||
| 36 | 36 | type Config struct { | |
| 37 | 37 | // Addr is the listen address, "host:port". | |
| 38 | 38 | Addr string | |
| 39 | - | // HealthChecks run on every GET /-/health; one failure answers 503. | |
| 39 | + | // PublicURL is the public base URL of the server. When it is https://, | |
| 40 | + | // every response carries Strict-Transport-Security. | |
| 41 | + | PublicURL string | |
| 42 | + | // HealthChecks run on GET /-/health, at most once per healthCacheTTL | |
| 43 | + | // (the answer is cached, the endpoint being unauthenticated); one | |
| 44 | + | // failure answers 503. | |
| 40 | 45 | HealthChecks []Check | |
| 41 | 46 | // Routes are added to the router next to /-/health. | |
| 42 | 47 | Routes []Route | |
| @@ -71,7 +76,7 @@ func New(cfg Config) *Server { | |||
| 71 | 76 | for _, r := range cfg.Routes { | |
| 72 | 77 | mux.Handle(r.Pattern, r.Handler) | |
| 73 | 78 | } | |
| 74 | - | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux)), onShutdown: cfg.OnShutdown} | |
| 79 | + | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux, isHTTPS(cfg.PublicURL))), onShutdown: cfg.OnShutdown} | |
| 75 | 80 | } | |
| 76 | 81 | ||
| 77 | 82 | // Handler returns the root handler, for tests. | |
M internal/adapters/httpserver/server_test.go +76 -1
| @@ -14,7 +14,9 @@ import ( | |||
| 14 | 14 | "os" | |
| 15 | 15 | "strings" | |
| 16 | 16 | "sync" | |
| 17 | + | "sync/atomic" | |
| 17 | 18 | "testing" | |
| 19 | + | "testing/synctest" | |
| 18 | 20 | "time" | |
| 19 | 21 | ||
| 20 | 22 | "github.com/bnema/zerowrap" | |
| @@ -25,7 +27,7 @@ import ( | |||
| 25 | 27 | ) | |
| 26 | 28 | ||
| 27 | 29 | var securityHeaders = map[string]string{ | |
| 28 | - | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'", | |
| 30 | + | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'", | |
| 29 | 31 | "X-Frame-Options": "DENY", | |
| 30 | 32 | "X-Content-Type-Options": "nosniff", | |
| 31 | 33 | "Referrer-Policy": "same-origin", | |
| @@ -76,6 +78,79 @@ func TestHealth(t *testing.T) { | |||
| 76 | 78 | } | |
| 77 | 79 | } | |
| 78 | 80 | ||
| 81 | + | func TestHealthIsCached(t *testing.T) { | |
| 82 | + | synctest.Test(t, func(t *testing.T) { | |
| 83 | + | var calls atomic.Int32 | |
| 84 | + | var failing atomic.Bool | |
| 85 | + | check := httpserver.Check(func(context.Context) error { | |
| 86 | + | calls.Add(1) | |
| 87 | + | if failing.Load() { | |
| 88 | + | return errors.New("down") | |
| 89 | + | } | |
| 90 | + | return nil | |
| 91 | + | }) | |
| 92 | + | h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler() | |
| 93 | + | ||
| 94 | + | steps := []struct { | |
| 95 | + | name string | |
| 96 | + | sleep time.Duration | |
| 97 | + | fail bool | |
| 98 | + | wantStatus int | |
| 99 | + | wantCalls int32 | |
| 100 | + | }{ | |
| 101 | + | {"first request runs the checks", 0, false, 200, 1}, | |
| 102 | + | {"a burst reuses the result", 4 * time.Second, true, 200, 1}, | |
| 103 | + | {"after the TTL the checks run again", time.Second, true, 503, 2}, | |
| 104 | + | {"a failure is cached too", time.Second, false, 503, 2}, | |
| 105 | + | {"and recovers after the TTL", 4 * time.Second, false, 200, 3}, | |
| 106 | + | } | |
| 107 | + | for _, st := range steps { | |
| 108 | + | time.Sleep(st.sleep) | |
| 109 | + | failing.Store(st.fail) | |
| 110 | + | rec := do(t, h, "GET", "/-/health") | |
| 111 | + | assert.Equal(t, st.wantStatus, rec.Code, st.name) | |
| 112 | + | assert.Equal(t, st.wantCalls, calls.Load(), st.name) | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestHealthConcurrentRequestsShareOneRun(t *testing.T) { | |
| 118 | + | var calls atomic.Int32 | |
| 119 | + | check := httpserver.Check(func(context.Context) error { calls.Add(1); return nil }) | |
| 120 | + | h := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{check}}).Handler() | |
| 121 | + | ||
| 122 | + | var wg sync.WaitGroup | |
| 123 | + | for range 20 { | |
| 124 | + | wg.Go(func() { assert.Equal(t, 200, do(t, h, "GET", "/-/health").Code) }) | |
| 125 | + | } | |
| 126 | + | wg.Wait() | |
| 127 | + | assert.Equal(t, int32(1), calls.Load()) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestStrictTransportSecurity(t *testing.T) { | |
| 131 | + | tests := []struct { | |
| 132 | + | name string | |
| 133 | + | publicURL string | |
| 134 | + | want string | |
| 135 | + | }{ | |
| 136 | + | {"https", "https://forge.example.com", "max-age=31536000"}, | |
| 137 | + | {"https uppercase scheme", "HTTPS://forge.example.com", "max-age=31536000"}, | |
| 138 | + | {"http", "http://forge.example.com", ""}, | |
| 139 | + | {"http loopback", "http://127.0.0.1:7341", ""}, | |
| 140 | + | {"unset", "", ""}, | |
| 141 | + | {"garbage", "://%", ""}, | |
| 142 | + | } | |
| 143 | + | for _, tt := range tests { | |
| 144 | + | t.Run(tt.name, func(t *testing.T) { | |
| 145 | + | h := httpserver.New(httpserver.Config{PublicURL: tt.publicURL}).Handler() | |
| 146 | + | for _, target := range []string{"/-/health", "/nope"} { | |
| 147 | + | rec := do(t, h, "GET", target) | |
| 148 | + | assert.Equal(t, tt.want, rec.Header().Get("Strict-Transport-Security"), target) | |
| 149 | + | } | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 79 | 154 | func TestHeadOverTheWire(t *testing.T) { | |
| 80 | 155 | ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler()) | |
| 81 | 156 | defer ts.Close() | |
M internal/adapters/kvstore/store.go +14 -2
| @@ -58,9 +58,21 @@ func (s *Store) Close(ctx context.Context) error { | |||
| 58 | 58 | ||
| 59 | 59 | // Check reports whether the database accepts writes. It returns the failure | |
| 60 | 60 | // once ermite-kv is closed or failed, and writes nothing. | |
| 61 | + | // | |
| 62 | + | // ermite-kv has no state accessor, and an empty batch sent through Apply would | |
| 63 | + | // queue behind the writes in progress. Apply looks at the state of the database | |
| 64 | + | // before it looks at its context, though, and an ended context stops it before | |
| 65 | + | // the writer channel: Apply on a cancelled context therefore answers ErrClosed | |
| 66 | + | // or ErrFailed when the database is stopped, and context.Canceled when it is | |
| 67 | + | // healthy, without waiting for the writer. | |
| 61 | 68 | func (s *Store) Check(ctx context.Context) error { | |
| 62 | - | var b kv.Batch // no ops: only the state of the database is checked | |
| 63 | - | if _, err := s.db.Apply(ctx, &b); err != nil { | |
| 69 | + | if err := ctx.Err(); err != nil { | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | probe, cancel := context.WithCancel(context.WithoutCancel(ctx)) | |
| 73 | + | cancel() | |
| 74 | + | var b kv.Batch | |
| 75 | + | if _, err := s.db.Apply(probe, &b); err != nil && !errors.Is(err, context.Canceled) { | |
| 64 | 76 | return fmt.Errorf("ermite-kv: %w", err) | |
| 65 | 77 | } | |
| 66 | 78 | return nil | |
M internal/adapters/kvstore/store_test.go +6 -0
| @@ -144,6 +144,12 @@ func TestCheck(t *testing.T) { | |||
| 144 | 144 | s, err := kvstore.Open(ctx, t.TempDir()) | |
| 145 | 145 | require.NoError(t, err) | |
| 146 | 146 | require.NoError(t, s.Check(ctx)) | |
| 147 | + | ||
| 148 | + | // The caller's own cancellation is reported, not mistaken for health. | |
| 149 | + | cancelled, cancel := context.WithCancel(ctx) | |
| 150 | + | cancel() | |
| 151 | + | assert.ErrorIs(t, s.Check(cancelled), context.Canceled) | |
| 152 | + | ||
| 147 | 153 | require.NoError(t, s.Close(ctx)) | |
| 148 | 154 | assert.ErrorIs(t, s.Check(ctx), kv.ErrClosed) | |
| 149 | 155 | } | |
M internal/adapters/rpc/api.go +18 -22
| @@ -2,9 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | // Package rpc serves the ermite API contract (bnema.dev/ermite-api) with | |
| 4 | 4 | // ConnectRPC: handlers that map the messages of the contract to the use cases | |
| 5 | - | // of app and back, the interceptor that authenticates callers, and the error | |
| 6 | - | // mapping. The same handlers serve remote clients over HTTP and the web in | |
| 7 | - | // process (see InProcessClient). | |
| 5 | + | // of usecase and back, the interceptor that authenticates callers, and the error | |
| 6 | + | // mapping. | |
| 8 | 7 | package rpc | |
| 9 | 8 | ||
| 10 | 9 | import ( | |
| @@ -16,9 +15,9 @@ import ( | |||
| 16 | 15 | "github.com/bnema/zerowrap" | |
| 17 | 16 | ||
| 18 | 17 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 19 | - | "bnema.dev/ermite/internal/app" | |
| 20 | 18 | "bnema.dev/ermite/internal/clientlimit" | |
| 21 | 19 | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | 21 | ) | |
| 23 | 22 | ||
| 24 | 23 | // MaxRequestBytes bounds the size of a request message. Responses are not | |
| @@ -29,31 +28,28 @@ const MaxRequestBytes = 1 << 20 | |||
| 29 | 28 | // implements, as GetVersion lists them. | |
| 30 | 29 | var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"} | |
| 31 | 30 | ||
| 32 | - | // Authenticator turns a token secret into a principal, or | |
| 33 | - | // domain.ErrUnauthenticated. app.Auth fits. | |
| 34 | - | type Authenticator interface { | |
| 35 | - | Authenticate(ctx context.Context, secret string) (domain.Principal, error) | |
| 36 | - | } | |
| 37 | - | ||
| 38 | 31 | // Config configures the API. | |
| 39 | 32 | type Config struct { | |
| 40 | - | Auth Authenticator | |
| 41 | - | Repos *app.Repos | |
| 42 | - | Tokens *app.Tokens | |
| 43 | - | Browse *app.Browse | |
| 44 | - | Events *app.Events | |
| 33 | + | Auth ports.AuthService | |
| 34 | + | Repos ports.RepoService | |
| 35 | + | Tokens ports.TokenService | |
| 36 | + | Browse ports.BrowseService | |
| 37 | + | Events ports.EventService | |
| 45 | 38 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | |
| 46 | - | MergeRequests *app.MergeRequests | |
| 47 | - | SigningKey *app.SigningKey | |
| 39 | + | MergeRequests ports.MergeRequestService | |
| 40 | + | SigningKey ports.SigningKeyService | |
| 48 | 41 | Version string | |
| 49 | 42 | // TrustedProxies are the networks allowed to set X-Forwarded-For. | |
| 50 | 43 | TrustedProxies []netip.Prefix | |
| 51 | 44 | // Limiter counts failed authentications; nil builds a default one. Share | |
| 52 | 45 | // it with the other adapters that authenticate, so that a client has one | |
| 53 | - | // budget of failures. | |
| 54 | - | Limiter *clientlimit.Limiter | |
| 46 | + | // budget of failures. Leave it unset rather than assigning a typed nil | |
| 47 | + | // pointer (a nil *T in the interface is not nil, and would be called). | |
| 48 | + | Limiter ports.FailureLimiter | |
| 55 | 49 | // Sessions are the owner's web sessions; nil serves no SessionService. | |
| 56 | - | Sessions *app.Sessions | |
| 50 | + | // Leave it unset rather than assigning a typed nil pointer (a nil *T in | |
| 51 | + | // the interface is not nil, and the service would be served and called). | |
| 52 | + | Sessions ports.SessionService | |
| 57 | 53 | } | |
| 58 | 54 | ||
| 59 | 55 | // API is the set of Connect handlers of the contract. | |
| @@ -93,8 +89,8 @@ func New(cfg Config) *API { | |||
| 93 | 89 | // it under /-/api/ and strips the prefix. | |
| 94 | 90 | func (a *API) Handler() http.Handler { return a.handler } | |
| 95 | 91 | ||
| 96 | - | // caller returns the principal the interceptor (or the in-process caller) | |
| 97 | - | // put in ctx; anonymous when there is none. | |
| 92 | + | // caller returns the principal the interceptor put in ctx; anonymous when | |
| 93 | + | // there is none. | |
| 98 | 94 | func caller(ctx context.Context) domain.Principal { | |
| 99 | 95 | p, _ := domain.PrincipalFromContext(ctx) | |
| 100 | 96 | return p | |
M internal/adapters/rpc/convert.go +4 -4
| @@ -9,8 +9,8 @@ import ( | |||
| 9 | 9 | "google.golang.org/protobuf/types/known/timestamppb" | |
| 10 | 10 | ||
| 11 | 11 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | - | "bnema.dev/ermite/internal/app" | |
| 13 | 12 | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // This file maps domain values to the messages of the contract and back. | |
| @@ -55,7 +55,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) { | |||
| 55 | 55 | return "", fmt.Errorf("%w: %v", domain.ErrInvalidVisibility, v) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | func repoToPB(v app.RepoView) *pb.Repo { | |
| 58 | + | func repoToPB(v ports.RepoView) *pb.Repo { | |
| 59 | 59 | settings := v.Settings | |
| 60 | 60 | if settings.IsZero() { | |
| 61 | 61 | settings = domain.DefaultRepoSettings() | |
| @@ -361,7 +361,7 @@ func refUpdatesToPB(updates []domain.RefUpdate) []*pb.RefUpdate { | |||
| 361 | 361 | ||
| 362 | 362 | // watchEventToPB maps one item of a watch; nil when the event has no | |
| 363 | 363 | // representation in the contract. | |
| 364 | - | func watchEventToPB(we app.WatchEvent) *pb.Event { | |
| 364 | + | func watchEventToPB(we ports.WatchEvent) *pb.Event { | |
| 365 | 365 | if we.Gap { | |
| 366 | 366 | return &pb.Event{Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | |
| 367 | 367 | } | |
| @@ -397,7 +397,7 @@ func watchEventToPB(we app.WatchEvent) *pb.Event { | |||
| 397 | 397 | return out | |
| 398 | 398 | } | |
| 399 | 399 | ||
| 400 | - | func repoOrName(we app.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo { | |
| 400 | + | func repoOrName(we ports.WatchEvent, name domain.RepoName, vis domain.Visibility) *pb.Repo { | |
| 401 | 401 | if we.Repo != nil { | |
| 402 | 402 | return repoToPB(*we.Repo) | |
| 403 | 403 | } | |
M internal/adapters/rpc/convert_test.go +21 -5
| @@ -15,15 +15,17 @@ import ( | |||
| 15 | 15 | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 16 | 16 | ||
| 17 | 17 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | // Every value of the domain maps to a real value of the contract, never to | |
| 23 | 23 | // UNSPECIFIED, and the mappers that have an inverse round-trip. A value added | |
| 24 | 24 | // to the domain and forgotten here would reach clients as "not set". | |
| 25 | 25 | func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |
| 26 | + | t.Parallel() | |
| 26 | 27 | t.Run("visibility", func(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 27 | 29 | for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} { | |
| 28 | 30 | got := visibilityToPB(v) | |
| 29 | 31 | assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v) | |
| @@ -37,6 +39,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 37 | 39 | }) | |
| 38 | 40 | ||
| 39 | 41 | t.Run("scope", func(t *testing.T) { | |
| 42 | + | t.Parallel() | |
| 40 | 43 | for _, s := range domain.AllScopes() { | |
| 41 | 44 | got, ok := scopeToPB[s] | |
| 42 | 45 | require.True(t, ok, "scope %s has no counterpart in the contract", s) | |
| @@ -58,6 +61,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 58 | 61 | }) | |
| 59 | 62 | ||
| 60 | 63 | t.Run("ref kind", func(t *testing.T) { | |
| 64 | + | t.Parallel() | |
| 61 | 65 | for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} { | |
| 62 | 66 | got := refKindToPB(k) | |
| 63 | 67 | assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k) | |
| @@ -70,6 +74,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 70 | 74 | }) | |
| 71 | 75 | ||
| 72 | 76 | t.Run("entry kind", func(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 73 | 78 | seen := map[pb.EntryKind]domain.EntryKind{} | |
| 74 | 79 | for _, k := range []domain.EntryKind{ | |
| 75 | 80 | domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule, | |
| @@ -82,6 +87,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 82 | 87 | }) | |
| 83 | 88 | ||
| 84 | 89 | t.Run("file status", func(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 85 | 91 | seen := map[pb.FileStatus]domain.FileStatus{} | |
| 86 | 92 | for _, s := range []domain.FileStatus{ | |
| 87 | 93 | domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged, | |
| @@ -94,6 +100,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 94 | 100 | }) | |
| 95 | 101 | ||
| 96 | 102 | t.Run("line kind", func(t *testing.T) { | |
| 103 | + | t.Parallel() | |
| 97 | 104 | seen := map[pb.LineKind]domain.LineKind{} | |
| 98 | 105 | for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} { | |
| 99 | 106 | got := lineKindToPB(k) | |
| @@ -106,6 +113,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 106 | 113 | // Every value of the contract except UNSPECIFIED is produced by some value | |
| 107 | 114 | // of the domain: nothing in the contract is unreachable. | |
| 108 | 115 | t.Run("the contract has no value the domain cannot produce", func(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 109 | 117 | produced := func(names map[int32]string, from func(v int32) bool) { | |
| 110 | 118 | for v, n := range names { | |
| 111 | 119 | if v == 0 { | |
| @@ -142,6 +150,7 @@ func TestEnumMappersCoverEveryDomainValue(t *testing.T) { | |||
| 142 | 150 | } | |
| 143 | 151 | ||
| 144 | 152 | func TestMergeModeMappers(t *testing.T) { | |
| 153 | + | t.Parallel() | |
| 145 | 154 | for _, m := range domain.MergeModes() { | |
| 146 | 155 | got := mergeModeToPB(m) | |
| 147 | 156 | assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m) | |
| @@ -168,6 +177,7 @@ func TestMergeModeMappers(t *testing.T) { | |||
| 168 | 177 | } | |
| 169 | 178 | ||
| 170 | 179 | func TestMergeRequestEnumMappers(t *testing.T) { | |
| 180 | + | t.Parallel() | |
| 171 | 181 | for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} { | |
| 172 | 182 | got := mergeRequestStateToPB(s) | |
| 173 | 183 | assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s) | |
| @@ -192,6 +202,7 @@ func TestMergeRequestEnumMappers(t *testing.T) { | |||
| 192 | 202 | } | |
| 193 | 203 | ||
| 194 | 204 | func TestCommentID(t *testing.T) { | |
| 205 | + | t.Parallel() | |
| 195 | 206 | assert.EqualValues(t, 7, commentID("7")) | |
| 196 | 207 | for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} { | |
| 197 | 208 | assert.Zero(t, commentID(bad), "%q names no comment", bad) | |
| @@ -199,13 +210,14 @@ func TestCommentID(t *testing.T) { | |||
| 199 | 210 | } | |
| 200 | 211 | ||
| 201 | 212 | func TestRepoToPBAlwaysHasSettings(t *testing.T) { | |
| 213 | + | t.Parallel() | |
| 202 | 214 | // A repository value without settings (never stored with them) reads as the defaults. | |
| 203 | - | r := repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | |
| 215 | + | r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}}) | |
| 204 | 216 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | |
| 205 | 217 | assert.True(t, r.GetSettings().GetFailingChecksBlock()) | |
| 206 | 218 | ||
| 207 | 219 | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}} | |
| 208 | - | r = repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}}) | |
| 220 | + | r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}}) | |
| 209 | 221 | assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes()) | |
| 210 | 222 | assert.False(t, r.GetSettings().GetFailingChecksBlock()) | |
| 211 | 223 | } | |
| @@ -218,6 +230,7 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||
| 218 | 230 | } | |
| 219 | 231 | ||
| 220 | 232 | func TestUpdateParamsSettingsPaths(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 221 | 234 | msg := func(paths ...string) *pb.UpdateRepoRequest { | |
| 222 | 235 | return &pb.UpdateRepoRequest{ | |
| 223 | 236 | UpdateMask: &fieldmaskpb.FieldMask{Paths: paths}, | |
| @@ -255,6 +268,7 @@ func TestUpdateParamsSettingsPaths(t *testing.T) { | |||
| 255 | 268 | } | |
| 256 | 269 | ||
| 257 | 270 | func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) { | |
| 271 | + | t.Parallel() | |
| 258 | 272 | var v domain.Violations | |
| 259 | 273 | v.Add("title", "must be one line") | |
| 260 | 274 | v.Add("source_branch", "does not exist") | |
| @@ -282,6 +296,7 @@ func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) { | |||
| 282 | 296 | } | |
| 283 | 297 | ||
| 284 | 298 | func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |
| 299 | + | t.Parallel() | |
| 285 | 300 | repo := mustName(t, "proj") | |
| 286 | 301 | title, err := domain.ParseMRTitle("Add x") | |
| 287 | 302 | require.NoError(t, err) | |
| @@ -297,7 +312,8 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |||
| 297 | 312 | "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }}, | |
| 298 | 313 | } { | |
| 299 | 314 | t.Run(name, func(t *testing.T) { | |
| 300 | - | got := watchEventToPB(app.WatchEvent{ID: "e1", Event: tc.ev}) | |
| 315 | + | t.Parallel() | |
| 316 | + | got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev}) | |
| 301 | 317 | require.NotNil(t, got) | |
| 302 | 318 | assert.Equal(t, "e1", got.GetId()) | |
| 303 | 319 | assert.Equal(t, at, got.GetTime().AsTime()) | |
| @@ -311,7 +327,7 @@ func TestWatchEventToPBMergeRequestEvents(t *testing.T) { | |||
| 311 | 327 | } | |
| 312 | 328 | ||
| 313 | 329 | c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at} | |
| 314 | - | got := watchEventToPB(app.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}}) | |
| 330 | + | got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}}) | |
| 315 | 331 | added := got.GetCommentAdded() | |
| 316 | 332 | require.NotNil(t, added) | |
| 317 | 333 | assert.Equal(t, "proj", added.GetRepo()) | |
M internal/adapters/rpc/errors.go +2 -13
| @@ -56,23 +56,12 @@ func codeOf(err error) (connect.Code, bool) { | |||
| 56 | 56 | case errors.Is(err, domain.ErrResourceExhausted): | |
| 57 | 57 | return connect.CodeResourceExhausted, true | |
| 58 | 58 | } | |
| 59 | - | for _, invalid := range invalidErrors { | |
| 60 | - | if errors.Is(err, invalid) { | |
| 61 | - | return connect.CodeInvalidArgument, true | |
| 62 | - | } | |
| 59 | + | if domain.IsInvalid(err) { | |
| 60 | + | return connect.CodeInvalidArgument, true | |
| 63 | 61 | } | |
| 64 | 62 | return 0, false | |
| 65 | 63 | } | |
| 66 | 64 | ||
| 67 | - | // invalidErrors are the domain errors that mean the request was malformed. | |
| 68 | - | var invalidErrors = []error{ | |
| 69 | - | domain.ErrInvalid, domain.ErrInvalidName, domain.ErrInvalidVisibility, | |
| 70 | - | domain.ErrInvalidScope, domain.ErrInvalidToken, | |
| 71 | - | domain.ErrInvalidObjectID, domain.ErrInvalidRevision, domain.ErrInvalidPath, | |
| 72 | - | domain.ErrInvalidPageToken, domain.ErrInvalidPageSize, domain.ErrInvalidRefKind, | |
| 73 | - | domain.ErrNotDirectory, domain.ErrNotFile, domain.ErrInvalidText, | |
| 74 | - | } | |
| 75 | - | ||
| 76 | 65 | // addFieldDetail attaches the fields a request was rejected for as a | |
| 77 | 66 | // BadRequest detail, when err carries them. | |
| 78 | 67 | func addFieldDetail(ce *connect.Error, err error) { | |
M internal/adapters/rpc/event.go +4 -4
| @@ -8,11 +8,11 @@ import ( | |||
| 8 | 8 | "connectrpc.com/connect" | |
| 9 | 9 | ||
| 10 | 10 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | - | "bnema.dev/ermite/internal/app" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // eventService implements ermiteconnect.EventServiceHandler. | |
| 15 | - | type eventService struct{ events *app.Events } | |
| 15 | + | type eventService struct{ events ports.EventService } | |
| 16 | 16 | ||
| 17 | 17 | // Watch streams events until the client goes away or the server stops. The | |
| 18 | 18 | // stream ends with success in both cases: the contract has no error for | |
| @@ -20,7 +20,7 @@ type eventService struct{ events *app.Events } | |||
| 20 | 20 | // the caller is revoked or expires, and a caller that has too many streams | |
| 21 | 21 | // open gets resource_exhausted. | |
| 22 | 22 | func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], stream *connect.ServerStream[pb.WatchResponse]) error { | |
| 23 | - | params := app.WatchParams{ | |
| 23 | + | params := ports.WatchParams{ | |
| 24 | 24 | Topics: req.Msg.GetTopics(), ResumeAfter: req.Msg.GetResumeAfter(), | |
| 25 | 25 | Client: clientFromContext(ctx), | |
| 26 | 26 | // A client's call returns when the response begins: send the headers | |
| @@ -28,7 +28,7 @@ func (s *eventService) Watch(ctx context.Context, req *connect.Request[pb.WatchR | |||
| 28 | 28 | // them for a nil message. A failure shows on the first real send. | |
| 29 | 29 | Ready: func() { _ = stream.Send(nil) }, | |
| 30 | 30 | } | |
| 31 | - | err := s.events.Watch(ctx, caller(ctx), params, func(we app.WatchEvent) error { | |
| 31 | + | err := s.events.Watch(ctx, caller(ctx), params, func(we ports.WatchEvent) error { | |
| 32 | 32 | ev := watchEventToPB(we) | |
| 33 | 33 | if ev == nil { | |
| 34 | 34 | return nil | |
M internal/adapters/rpc/git.go +4 -4
| @@ -11,15 +11,15 @@ import ( | |||
| 11 | 11 | "connectrpc.com/connect" | |
| 12 | 12 | ||
| 13 | 13 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | - | "bnema.dev/ermite/internal/app" | |
| 15 | 14 | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | // BlobChunkSize is the largest chunk of a GetBlob stream. | |
| 19 | 19 | const BlobChunkSize = 64 << 10 | |
| 20 | 20 | ||
| 21 | 21 | // gitService implements ermiteconnect.GitServiceHandler. | |
| 22 | - | type gitService struct{ browse *app.Browse } | |
| 22 | + | type gitService struct{ browse ports.BrowseService } | |
| 23 | 23 | ||
| 24 | 24 | // target is the repository and revision most requests start with. | |
| 25 | 25 | func target(repo, revision string) (domain.RepoName, domain.Revision, error) { | |
| @@ -39,7 +39,7 @@ func (s *gitService) ListRefs(ctx context.Context, req *connect.Request[pb.ListR | |||
| 39 | 39 | if err != nil { | |
| 40 | 40 | return nil, connectError(ctx, err) | |
| 41 | 41 | } | |
| 42 | - | params := app.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()} | |
| 42 | + | params := ports.ListRefsParams{PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken()} | |
| 43 | 43 | for _, k := range req.Msg.GetKinds() { | |
| 44 | 44 | kind, err := refKindFromPB(k) | |
| 45 | 45 | if err != nil { | |
| @@ -162,7 +162,7 @@ func (s *gitService) ListCommits(ctx context.Context, req *connect.Request[pb.Li | |||
| 162 | 162 | if err != nil { | |
| 163 | 163 | return nil, connectError(ctx, err) | |
| 164 | 164 | } | |
| 165 | - | page, err := s.browse.ListCommits(ctx, caller(ctx), name, app.ListCommitsParams{ | |
| 165 | + | page, err := s.browse.ListCommits(ctx, caller(ctx), name, ports.ListCommitsParams{ | |
| 166 | 166 | Revision: rev, Path: path, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | |
| 167 | 167 | }) | |
| 168 | 168 | if err != nil { | |
A internal/adapters/rpc/git_test.go +225 -0
| @@ -0,0 +1,225 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestGitServiceGetTree(t *testing.T) { | |
| 21 | + | t.Parallel() | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | req *pb.GetTreeRequest | |
| 25 | + | usecase error | |
| 26 | + | wantCode connect.Code | |
| 27 | + | wantCall bool | |
| 28 | + | }{ | |
| 29 | + | {name: "ok", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "src"}, wantCall: true}, | |
| 30 | + | {name: "invalid repo", req: &pb.GetTreeRequest{Repo: "../x", Revision: "main"}, wantCode: connect.CodeInvalidArgument}, | |
| 31 | + | {name: "invalid path", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "a/../b"}, wantCode: connect.CodeInvalidArgument}, | |
| 32 | + | {name: "repo not visible", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 33 | + | {name: "path is a file", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "f"}, usecase: domain.ErrNotDirectory, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |
| 34 | + | } | |
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | t.Parallel() | |
| 38 | + | browse := mocks.NewBrowseService(t) | |
| 39 | + | if tt.wantCall { | |
| 40 | + | browse.EXPECT().GetTree(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything, false). | |
| 41 | + | Return(domain.Tree{}, tt.usecase).Once() | |
| 42 | + | } | |
| 43 | + | resp, err := (&gitService{browse}).GetTree(ownerCtx(), connect.NewRequest(tt.req)) | |
| 44 | + | if tt.wantCode != 0 { | |
| 45 | + | requireCode(t, tt.wantCode, err) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Empty(t, resp.Msg.GetEntries()) | |
| 50 | + | }) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func mustObjectID(t *testing.T, s string) domain.ObjectID { | |
| 55 | + | t.Helper() | |
| 56 | + | id, err := domain.ParseObjectID(s) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | return id | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestGitServiceListPaths(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | commit := mustObjectID(t, strings.Repeat("a", 40)) | |
| 64 | + | tree := mustObjectID(t, strings.Repeat("b", 40)) | |
| 65 | + | tests := []struct { | |
| 66 | + | name string | |
| 67 | + | req *pb.ListPathsRequest | |
| 68 | + | list domain.PathList | |
| 69 | + | usecase error | |
| 70 | + | wantCode connect.Code | |
| 71 | + | wantCall bool | |
| 72 | + | }{ | |
| 73 | + | { | |
| 74 | + | name: "ok", req: &pb.ListPathsRequest{Repo: "demo", Revision: "main"}, wantCall: true, | |
| 75 | + | list: domain.PathList{CommitID: commit, TreeID: tree, Paths: []string{"a.go", "b/c.go"}, Truncated: true}, | |
| 76 | + | }, | |
| 77 | + | {name: "default revision", req: &pb.ListPathsRequest{Repo: "demo"}, wantCall: true, list: domain.PathList{CommitID: commit, TreeID: tree}}, | |
| 78 | + | {name: "invalid repo", req: &pb.ListPathsRequest{Repo: "../x"}, wantCode: connect.CodeInvalidArgument}, | |
| 79 | + | {name: "repo not visible", req: &pb.ListPathsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 80 | + | {name: "revision not found", req: &pb.ListPathsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 81 | + | } | |
| 82 | + | for _, tt := range tests { | |
| 83 | + | t.Run(tt.name, func(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 85 | + | browse := mocks.NewBrowseService(t) | |
| 86 | + | if tt.wantCall { | |
| 87 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |
| 88 | + | Return(tt.list, tt.usecase).Once() | |
| 89 | + | } | |
| 90 | + | resp, err := (&gitService{browse}).ListPaths(ownerCtx(), connect.NewRequest(tt.req)) | |
| 91 | + | if tt.wantCode != 0 { | |
| 92 | + | requireCode(t, tt.wantCode, err) | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | assert.Equal(t, tt.list.CommitID.String(), resp.Msg.GetCommitId()) | |
| 97 | + | assert.Equal(t, tt.list.TreeID.String(), resp.Msg.GetTreeId()) | |
| 98 | + | assert.Equal(t, tt.list.Paths, resp.Msg.GetPaths()) | |
| 99 | + | assert.Equal(t, tt.list.Truncated, resp.Msg.GetTruncated()) | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestGitServiceListRefs(t *testing.T) { | |
| 105 | + | t.Parallel() | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | req *pb.ListRefsRequest | |
| 109 | + | usecase error | |
| 110 | + | wantCode connect.Code | |
| 111 | + | wantCall bool | |
| 112 | + | }{ | |
| 113 | + | {name: "ok", req: &pb.ListRefsRequest{Repo: "demo", PageSize: 10}, wantCall: true}, | |
| 114 | + | {name: "unknown kind", req: &pb.ListRefsRequest{Repo: "demo", Kinds: []pb.RefKind{pb.RefKind(99)}}, wantCode: connect.CodeInvalidArgument}, | |
| 115 | + | {name: "invalid repo", req: &pb.ListRefsRequest{Repo: ""}, wantCode: connect.CodeInvalidArgument}, | |
| 116 | + | {name: "bad page token", req: &pb.ListRefsRequest{Repo: "demo", PageToken: "zz"}, usecase: domain.ErrInvalidPageToken, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |
| 117 | + | {name: "not found", req: &pb.ListRefsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 118 | + | } | |
| 119 | + | for _, tt := range tests { | |
| 120 | + | t.Run(tt.name, func(t *testing.T) { | |
| 121 | + | t.Parallel() | |
| 122 | + | browse := mocks.NewBrowseService(t) | |
| 123 | + | if tt.wantCall { | |
| 124 | + | browse.EXPECT().ListRefs(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |
| 125 | + | Return(ports.RefPage{NextPageToken: "n"}, tt.usecase).Once() | |
| 126 | + | } | |
| 127 | + | resp, err := (&gitService{browse}).ListRefs(ownerCtx(), connect.NewRequest(tt.req)) | |
| 128 | + | if tt.wantCode != 0 { | |
| 129 | + | requireCode(t, tt.wantCode, err) | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestGitServiceGetCommitAndResolveRevision(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 140 | + | invalid := []struct{ name, repo, rev string }{ | |
| 141 | + | {"invalid repo", "../x", "main"}, | |
| 142 | + | {"invalid revision", "demo", "a b"}, | |
| 143 | + | } | |
| 144 | + | for _, tt := range invalid { | |
| 145 | + | t.Run("GetCommit "+tt.name, func(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 147 | + | _, err := (&gitService{mocks.NewBrowseService(t)}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: tt.repo, Revision: tt.rev})) | |
| 148 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 149 | + | }) | |
| 150 | + | t.Run("ResolveRevision "+tt.name, func(t *testing.T) { | |
| 151 | + | t.Parallel() | |
| 152 | + | _, err := (&gitService{mocks.NewBrowseService(t)}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: tt.repo, Revision: tt.rev})) | |
| 153 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 154 | + | }) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | t.Run("GetCommit not found", func(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | browse := mocks.NewBrowseService(t) | |
| 160 | + | browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once() | |
| 161 | + | _, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"})) | |
| 162 | + | requireCode(t, connect.CodeNotFound, err) | |
| 163 | + | }) | |
| 164 | + | t.Run("ResolveRevision ok", func(t *testing.T) { | |
| 165 | + | t.Parallel() | |
| 166 | + | browse := mocks.NewBrowseService(t) | |
| 167 | + | browse.EXPECT().ResolveRevision(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |
| 168 | + | Return(domain.ResolvedRevision{Ref: "refs/heads/main"}, nil).Once() | |
| 169 | + | resp, err := (&gitService{browse}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: "demo", Revision: "main"})) | |
| 170 | + | require.NoError(t, err) | |
| 171 | + | assert.Equal(t, "refs/heads/main", resp.Msg.GetRef()) | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestGitServiceListCommits(t *testing.T) { | |
| 176 | + | t.Parallel() | |
| 177 | + | tests := []struct { | |
| 178 | + | name string | |
| 179 | + | req *pb.ListCommitsRequest | |
| 180 | + | usecase error | |
| 181 | + | wantCode connect.Code | |
| 182 | + | wantCall bool | |
| 183 | + | }{ | |
| 184 | + | {name: "ok", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main"}, wantCall: true}, | |
| 185 | + | {name: "invalid path", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", Path: "/abs/../x"}, wantCode: connect.CodeInvalidArgument}, | |
| 186 | + | {name: "bad page size", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", PageSize: -1}, usecase: domain.ErrInvalidPageSize, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |
| 187 | + | {name: "not found", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 188 | + | } | |
| 189 | + | for _, tt := range tests { | |
| 190 | + | t.Run(tt.name, func(t *testing.T) { | |
| 191 | + | t.Parallel() | |
| 192 | + | browse := mocks.NewBrowseService(t) | |
| 193 | + | if tt.wantCall { | |
| 194 | + | browse.EXPECT().ListCommits(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything). | |
| 195 | + | Return(ports.HistoryPage{}, tt.usecase).Once() | |
| 196 | + | } | |
| 197 | + | _, err := (&gitService{browse}).ListCommits(ownerCtx(), connect.NewRequest(tt.req)) | |
| 198 | + | if tt.wantCode != 0 { | |
| 199 | + | requireCode(t, tt.wantCode, err) | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // GetBlob validates before it opens anything or touches the stream. | |
| 208 | + | func TestGitServiceGetBlobRejectsBadInput(t *testing.T) { | |
| 209 | + | t.Parallel() | |
| 210 | + | tests := []struct { | |
| 211 | + | name string | |
| 212 | + | req *pb.GetBlobRequest | |
| 213 | + | }{ | |
| 214 | + | {"invalid repo", &pb.GetBlobRequest{Repo: "../x", Revision: "main", Path: "f"}}, | |
| 215 | + | {"missing path", &pb.GetBlobRequest{Repo: "demo", Revision: "main"}}, | |
| 216 | + | {"invalid path", &pb.GetBlobRequest{Repo: "demo", Revision: "main", Path: "a/../b"}}, | |
| 217 | + | } | |
| 218 | + | for _, tt := range tests { | |
| 219 | + | t.Run(tt.name, func(t *testing.T) { | |
| 220 | + | t.Parallel() | |
| 221 | + | err := (&gitService{mocks.NewBrowseService(t)}).GetBlob(ownerCtx(), connect.NewRequest(tt.req), nil) | |
| 222 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | } | |
D internal/adapters/rpc/inprocess.go +0 -131
| @@ -1,131 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package rpc | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | "sync" | |
| 10 | - | ||
| 11 | - | "bnema.dev/ermite/internal/domain" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // inProcessHost is the host of the URLs of in-process calls. The name is | |
| 15 | - | // never resolved. | |
| 16 | - | const inProcessHost = "ermite.invalid" | |
| 17 | - | ||
| 18 | - | // InProcessURL is the base URL to give to the generated Connect clients that | |
| 19 | - | // use InProcessClient. | |
| 20 | - | const InProcessURL = "http://" + inProcessHost | |
| 21 | - | ||
| 22 | - | // InProcessClient returns an HTTP client whose requests are served by the | |
| 23 | - | // handlers of the API in this process: no socket, no network hop. Build the | |
| 24 | - | // generated Connect clients on it: | |
| 25 | - | // | |
| 26 | - | // hc := api.InProcessClient() | |
| 27 | - | // repos := ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL) | |
| 28 | - | // | |
| 29 | - | // The caller is the principal found in the context of each call | |
| 30 | - | // (domain.ContextWithPrincipal), anonymous when there is none; the | |
| 31 | - | // Authorization header is ignored. This is the identity of the web (P5): | |
| 32 | - | // anonymous for visitors, the owner for a session. Only code in this process | |
| 33 | - | // can put the marker that makes the interceptor trust it. | |
| 34 | - | // Unary and server-streaming calls work, with all the codecs; the request | |
| 35 | - | // log and the error mapping are those of remote calls. | |
| 36 | - | func (a *API) InProcessClient() *http.Client { | |
| 37 | - | return &http.Client{Transport: inProcessTransport{h: a.handler}} | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | type inProcessTransport struct{ h http.Handler } | |
| 41 | - | ||
| 42 | - | // inProcessKey marks the context of a call made through InProcessClient. It is | |
| 43 | - | // unexported, so that nothing outside this package, and nothing that comes | |
| 44 | - | // from the network, can make the interceptor trust a principal. | |
| 45 | - | type inProcessKey struct{} | |
| 46 | - | ||
| 47 | - | func inProcess(ctx context.Context) bool { | |
| 48 | - | _, ok := ctx.Value(inProcessKey{}).(struct{}) | |
| 49 | - | return ok | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // RoundTrip serves req with the handler in a goroutine, and returns once the | |
| 53 | - | // handler wrote its status. The body of the response is a pipe the handler | |
| 54 | - | // writes to, so that streams are delivered as they are sent. | |
| 55 | - | func (t inProcessTransport) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 56 | - | ctx := req.Context() | |
| 57 | - | p, ok := domain.PrincipalFromContext(ctx) | |
| 58 | - | if !ok { | |
| 59 | - | p = domain.Anonymous() | |
| 60 | - | } | |
| 61 | - | sreq := req.Clone(context.WithValue(domain.ContextWithPrincipal(ctx, p), inProcessKey{}, struct{}{})) | |
| 62 | - | sreq.RemoteAddr = "in-process" | |
| 63 | - | sreq.RequestURI = req.URL.RequestURI() | |
| 64 | - | if sreq.Body == nil { | |
| 65 | - | sreq.Body = http.NoBody | |
| 66 | - | } | |
| 67 | - | sreq.Header.Del("Authorization") | |
| 68 | - | ||
| 69 | - | pr, pw := io.Pipe() | |
| 70 | - | w := &pipeResponse{header: http.Header{}, pw: pw, ready: make(chan struct{})} | |
| 71 | - | go func() { | |
| 72 | - | defer func() { | |
| 73 | - | if r := recover(); r != nil { | |
| 74 | - | w.writeHeader(http.StatusInternalServerError) | |
| 75 | - | _ = pw.CloseWithError(io.ErrUnexpectedEOF) | |
| 76 | - | } | |
| 77 | - | }() | |
| 78 | - | t.h.ServeHTTP(w, sreq) | |
| 79 | - | w.writeHeader(http.StatusOK) // a handler that wrote nothing | |
| 80 | - | _ = pw.Close() | |
| 81 | - | }() | |
| 82 | - | ||
| 83 | - | select { | |
| 84 | - | case <-w.ready: | |
| 85 | - | case <-ctx.Done(): | |
| 86 | - | _ = pr.CloseWithError(ctx.Err()) | |
| 87 | - | return nil, ctx.Err() | |
| 88 | - | } | |
| 89 | - | return &http.Response{ | |
| 90 | - | Status: http.StatusText(w.status), | |
| 91 | - | StatusCode: w.status, | |
| 92 | - | Proto: "HTTP/1.1", | |
| 93 | - | ProtoMajor: 1, | |
| 94 | - | ProtoMinor: 1, | |
| 95 | - | Header: w.sent, | |
| 96 | - | Body: pr, | |
| 97 | - | ContentLength: -1, | |
| 98 | - | Request: req, | |
| 99 | - | }, nil | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // pipeResponse is the http.ResponseWriter of an in-process call. | |
| 103 | - | type pipeResponse struct { | |
| 104 | - | header http.Header | |
| 105 | - | pw *io.PipeWriter | |
| 106 | - | ||
| 107 | - | once sync.Once | |
| 108 | - | ready chan struct{} | |
| 109 | - | status int | |
| 110 | - | sent http.Header // header as of WriteHeader | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (w *pipeResponse) Header() http.Header { return w.header } | |
| 114 | - | ||
| 115 | - | func (w *pipeResponse) WriteHeader(code int) { w.writeHeader(code) } | |
| 116 | - | ||
| 117 | - | func (w *pipeResponse) writeHeader(code int) { | |
| 118 | - | w.once.Do(func() { | |
| 119 | - | w.status, w.sent = code, w.header.Clone() | |
| 120 | - | close(w.ready) | |
| 121 | - | }) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func (w *pipeResponse) Write(b []byte) (int, error) { | |
| 125 | - | w.writeHeader(http.StatusOK) | |
| 126 | - | return w.pw.Write(b) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // Flush implements http.Flusher, which streaming handlers require: the pipe | |
| 130 | - | // delivers every write at once, there is nothing to flush but the header. | |
| 131 | - | func (w *pipeResponse) Flush() { w.writeHeader(http.StatusOK) } | |
M internal/adapters/rpc/interceptor.go +7 -24
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 19 | 19 | "bnema.dev/ermite/internal/clientlimit" | |
| 20 | 20 | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | var errInternal = errors.New("internal error") | |
| @@ -30,15 +31,12 @@ var errInternal = errors.New("internal error") | |||
| 30 | 31 | // limiter; a client that used up its failures gets resource_exhausted (429 on | |
| 31 | 32 | // the Connect protocol) whatever it presents, like on the Git routes. | |
| 32 | 33 | // | |
| 33 | - | // A call made through InProcessClient is trusted with the principal its | |
| 34 | - | // context carries: that is how the web calls the handlers in process. What | |
| 35 | - | // marks it is a value only the in-process transport can put in the context | |
| 36 | - | // (see inProcessKey); a principal in the context of a network request, say one | |
| 37 | - | // put there by a server's base context, is ignored and the request is | |
| 38 | - | // authenticated from its headers. | |
| 34 | + | // A principal in the context of a network request, say one put there by a | |
| 35 | + | // server's base context, is ignored and the request is authenticated from its | |
| 36 | + | // headers. | |
| 39 | 37 | type interceptor struct { | |
| 40 | - | auth Authenticator | |
| 41 | - | limiter *clientlimit.Limiter | |
| 38 | + | auth ports.AuthService | |
| 39 | + | limiter ports.FailureLimiter | |
| 42 | 40 | trusted []netip.Prefix | |
| 43 | 41 | } | |
| 44 | 42 | ||
| @@ -48,11 +46,6 @@ func newInterceptor(cfg Config) *interceptor { | |||
| 48 | 46 | ||
| 49 | 47 | // authenticate returns the context to run the call in. | |
| 50 | 48 | func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr string, h http.Header) (context.Context, error) { | |
| 51 | - | if inProcess(ctx) { | |
| 52 | - | if _, ok := domain.PrincipalFromContext(ctx); ok { | |
| 53 | - | return ctx, nil | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | 49 | // Whatever the context carried, the caller is anonymous until proven | |
| 57 | 50 | // otherwise. | |
| 58 | 51 | ctx = domain.ContextWithPrincipal(ctx, domain.Anonymous()) | |
| @@ -90,18 +83,8 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri | |||
| 90 | 83 | ||
| 91 | 84 | type clientKey struct{} | |
| 92 | 85 | ||
| 93 | - | // ContextWithClient attributes the in-process calls made with ctx to the | |
| 94 | - | // client at addr (clientlimit.ClientAddr of the browser's request), so that | |
| 95 | - | // the per-client limits of the API, such as the event streams one, apply to | |
| 96 | - | // each visitor of the web rather than to the web as a whole. A network call | |
| 97 | - | // is always attributed to its own peer: the interceptor overwrites it. | |
| 98 | - | func ContextWithClient(ctx context.Context, addr string) context.Context { | |
| 99 | - | return context.WithValue(ctx, clientKey{}, clientlimit.Key(addr)) | |
| 100 | - | } | |
| 101 | - | ||
| 102 | 86 | // clientFromContext returns the normalized address of the client of a call: | |
| 103 | - | // its peer for a network call, the one given to ContextWithClient for an | |
| 104 | - | // in-process call, empty when there is none. | |
| 87 | + | // its peer, empty when there is none. | |
| 105 | 88 | func clientFromContext(ctx context.Context) string { | |
| 106 | 89 | c, _ := ctx.Value(clientKey{}).(string) | |
| 107 | 90 | return c | |
A internal/adapters/rpc/interceptor_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | const peer = "203.0.113.7:4242" | |
| 23 | + | ||
| 24 | + | func bearerHeader(v string) http.Header { | |
| 25 | + | h := http.Header{} | |
| 26 | + | if v != "" { | |
| 27 | + | h.Set("Authorization", v) | |
| 28 | + | } | |
| 29 | + | return h | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestInterceptorAuthenticate(t *testing.T) { | |
| 33 | + | t.Parallel() | |
| 34 | + | const procedure = ermiteconnect.RepoServiceListReposProcedure | |
| 35 | + | tests := []struct { | |
| 36 | + | name string | |
| 37 | + | procedure string | |
| 38 | + | header http.Header | |
| 39 | + | blocked bool | |
| 40 | + | authErr error | |
| 41 | + | wantCode connect.Code // 0: success | |
| 42 | + | wantAuth bool | |
| 43 | + | wantFail bool | |
| 44 | + | wantLimit bool // the limiter is asked at all | |
| 45 | + | wantRetry string | |
| 46 | + | wantOwner bool | |
| 47 | + | }{ | |
| 48 | + | {name: "no header is anonymous", procedure: procedure, header: bearerHeader(""), wantLimit: true}, | |
| 49 | + | {name: "valid token", procedure: procedure, header: bearerHeader("Bearer good"), wantLimit: true, wantAuth: true, wantOwner: true}, | |
| 50 | + | { | |
| 51 | + | name: "invalid token counts as a failure", procedure: procedure, header: bearerHeader("Bearer bad"), | |
| 52 | + | authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true, | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "non-bearer scheme is a failure", procedure: procedure, header: bearerHeader("Basic abc"), | |
| 56 | + | authErr: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantLimit: true, wantAuth: true, wantFail: true, | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "authenticator outage is not a failure of the client", procedure: procedure, header: bearerHeader("Bearer good"), | |
| 60 | + | authErr: errors.New("store down"), wantCode: connect.CodeInternal, wantLimit: true, wantAuth: true, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "blocked client is refused with Retry-After", procedure: procedure, header: bearerHeader("Bearer good"), | |
| 64 | + | blocked: true, wantCode: connect.CodeResourceExhausted, wantLimit: true, wantRetry: "30", | |
| 65 | + | }, | |
| 66 | + | {name: "version needs no authentication", procedure: ermiteconnect.ServerServiceGetVersionProcedure, header: bearerHeader("Bearer expired")}, | |
| 67 | + | {name: "signing key needs no authentication", procedure: ermiteconnect.ServerServiceGetSigningKeyProcedure, header: bearerHeader("Bearer expired")}, | |
| 68 | + | } | |
| 69 | + | for _, tt := range tests { | |
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |
| 71 | + | t.Parallel() | |
| 72 | + | auth := mocks.NewAuthService(t) | |
| 73 | + | limiter := mocks.NewFailureLimiter(t) | |
| 74 | + | if tt.wantLimit { | |
| 75 | + | wait := time.Duration(0) | |
| 76 | + | if tt.blocked { | |
| 77 | + | wait = 29*time.Second + 500*time.Millisecond | |
| 78 | + | } | |
| 79 | + | limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(tt.blocked, wait).Once() | |
| 80 | + | } | |
| 81 | + | if tt.wantAuth { | |
| 82 | + | p := domain.Principal{} | |
| 83 | + | if tt.wantOwner { | |
| 84 | + | p = domain.Owner() | |
| 85 | + | } | |
| 86 | + | auth.EXPECT().Authenticate(mock.Anything, mock.Anything).Return(p, tt.authErr).Once() | |
| 87 | + | } | |
| 88 | + | if tt.wantFail { | |
| 89 | + | limiter.EXPECT().Fail(mock.Anything, "203.0.113.7").Once() | |
| 90 | + | } | |
| 91 | + | i := newInterceptor(Config{Auth: auth, Limiter: limiter}) | |
| 92 | + | ctx, err := i.authenticate(context.Background(), tt.procedure, peer, tt.header) | |
| 93 | + | if tt.wantCode != 0 { | |
| 94 | + | requireCode(t, tt.wantCode, err) | |
| 95 | + | if tt.wantRetry != "" { | |
| 96 | + | var ce *connect.Error | |
| 97 | + | require.ErrorAs(t, err, &ce) | |
| 98 | + | assert.Equal(t, tt.wantRetry, ce.Meta().Get("Retry-After")) | |
| 99 | + | } | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | p, ok := domain.PrincipalFromContext(ctx) | |
| 104 | + | require.True(t, ok) | |
| 105 | + | if tt.wantOwner { | |
| 106 | + | assert.Equal(t, domain.Owner(), p) | |
| 107 | + | } else { | |
| 108 | + | assert.Equal(t, domain.Anonymous(), p) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestInterceptorIgnoresForwardedForFromUntrustedPeer(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | limiter := mocks.NewFailureLimiter(t) | |
| 117 | + | limiter.EXPECT().Blocked(mock.Anything, "203.0.113.7").Return(false, time.Duration(0)).Once() | |
| 118 | + | h := bearerHeader("") | |
| 119 | + | h.Set("X-Forwarded-For", "198.51.100.1") | |
| 120 | + | i := newInterceptor(Config{Auth: mocks.NewAuthService(t), Limiter: limiter}) | |
| 121 | + | _, err := i.authenticate(context.Background(), ermiteconnect.RepoServiceListReposProcedure, peer, h) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | } | |
M internal/adapters/rpc/mergerequest.go +6 -6
| @@ -10,12 +10,12 @@ import ( | |||
| 10 | 10 | "connectrpc.com/connect" | |
| 11 | 11 | ||
| 12 | 12 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | - | "bnema.dev/ermite/internal/app" | |
| 14 | 13 | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | // mergeRequestService implements ermiteconnect.MergeRequestServiceHandler. | |
| 18 | - | type mergeRequestService struct{ mrs *app.MergeRequests } | |
| 18 | + | type mergeRequestService struct{ mrs ports.MergeRequestService } | |
| 19 | 19 | ||
| 20 | 20 | func mergeRequestStateToPB(s domain.MRState) pb.MergeRequestState { | |
| 21 | 21 | switch s { | |
| @@ -129,7 +129,7 @@ func (s *mergeRequestService) CreateMergeRequest(ctx context.Context, req *conne | |||
| 129 | 129 | if err != nil { | |
| 130 | 130 | return nil, connectError(ctx, err) | |
| 131 | 131 | } | |
| 132 | - | mr, err := s.mrs.Create(ctx, caller(ctx), name, app.CreateMergeRequestParams{ | |
| 132 | + | mr, err := s.mrs.Create(ctx, caller(ctx), name, ports.CreateMergeRequestParams{ | |
| 133 | 133 | Title: req.Msg.GetTitle(), Description: req.Msg.GetDescription(), | |
| 134 | 134 | Source: req.Msg.GetSourceBranch(), Target: req.Msg.GetTargetBranch(), | |
| 135 | 135 | }) | |
| @@ -148,7 +148,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec | |||
| 148 | 148 | if err != nil { | |
| 149 | 149 | return nil, connectError(ctx, err) | |
| 150 | 150 | } | |
| 151 | - | page, err := s.mrs.List(ctx, caller(ctx), name, app.ListMergeRequestsParams{ | |
| 151 | + | page, err := s.mrs.List(ctx, caller(ctx), name, ports.ListMergeRequestsParams{ | |
| 152 | 152 | State: state, PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(), | |
| 153 | 153 | }) | |
| 154 | 154 | if err != nil { | |
| @@ -197,7 +197,7 @@ func (s *mergeRequestService) AddComment(ctx context.Context, req *connect.Reque | |||
| 197 | 197 | if err != nil { | |
| 198 | 198 | return nil, connectError(ctx, err) | |
| 199 | 199 | } | |
| 200 | - | c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), app.AddCommentParams{ | |
| 200 | + | c, err := s.mrs.AddComment(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.AddCommentParams{ | |
| 201 | 201 | Path: req.Msg.GetPath(), Line: int(req.Msg.GetLine()), Side: side, Text: req.Msg.GetText(), | |
| 202 | 202 | }) | |
| 203 | 203 | if err != nil { | |
| @@ -245,7 +245,7 @@ func (s *mergeRequestService) MergeMergeRequest(ctx context.Context, req *connec | |||
| 245 | 245 | if err != nil { | |
| 246 | 246 | return nil, connectError(ctx, err) | |
| 247 | 247 | } | |
| 248 | - | res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), app.MergeParams{ | |
| 248 | + | res, err := s.mrs.Merge(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.MergeParams{ | |
| 249 | 249 | Mode: mode, HeadID: head, DeleteSource: req.Msg.GetDeleteSourceBranch(), | |
| 250 | 250 | }) | |
| 251 | 251 | if err != nil { | |
A internal/adapters/rpc/mergerequest_test.go +227 -0
| @@ -0,0 +1,227 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestMergeRequestServiceCreate(t *testing.T) { | |
| 20 | + | t.Parallel() | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | repo string | |
| 24 | + | usecase error | |
| 25 | + | wantCode connect.Code | |
| 26 | + | wantCall bool | |
| 27 | + | }{ | |
| 28 | + | {name: "ok", repo: "demo", wantCall: true}, | |
| 29 | + | {name: "invalid repo", repo: "../x", wantCode: connect.CodeInvalidArgument}, | |
| 30 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 31 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 32 | + | {name: "already open", repo: "demo", usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true}, | |
| 33 | + | {name: "invalid fields", repo: "demo", usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |
| 34 | + | } | |
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | t.Parallel() | |
| 38 | + | mrs := mocks.NewMergeRequestService(t) | |
| 39 | + | if tt.wantCall { | |
| 40 | + | mrs.EXPECT().Create(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.CreateMergeRequestParams{ | |
| 41 | + | Title: "t", Description: "d", Source: "feat", Target: "main", | |
| 42 | + | }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once() | |
| 43 | + | } | |
| 44 | + | resp, err := (&mergeRequestService{mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{ | |
| 45 | + | Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main", | |
| 46 | + | })) | |
| 47 | + | if tt.wantCode != 0 { | |
| 48 | + | requireCode(t, tt.wantCode, err) | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber()) | |
| 53 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState()) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) { | |
| 59 | + | t.Parallel() | |
| 60 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(), | |
| 61 | + | connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)})) | |
| 62 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestMergeRequestServiceListFiltersByState(t *testing.T) { | |
| 66 | + | t.Parallel() | |
| 67 | + | mrs := mocks.NewMergeRequestService(t) | |
| 68 | + | mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{ | |
| 69 | + | State: domain.MRStateMerged, PageSize: 3, PageToken: "tok", | |
| 70 | + | }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once() | |
| 71 | + | resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{ | |
| 72 | + | Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok", | |
| 73 | + | })) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.Len(t, resp.Msg.GetMergeRequests(), 1) | |
| 76 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestMergeRequestServiceAddComment(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | tests := []struct { | |
| 82 | + | name string | |
| 83 | + | side pb.DiffSide | |
| 84 | + | usecase error | |
| 85 | + | wantCode connect.Code | |
| 86 | + | wantCall bool | |
| 87 | + | }{ | |
| 88 | + | {name: "ok", side: pb.DiffSide_DIFF_SIDE_NEW, wantCall: true}, | |
| 89 | + | {name: "unknown side", side: pb.DiffSide(99), wantCode: connect.CodeInvalidArgument}, | |
| 90 | + | {name: "merge request not open", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true}, | |
| 91 | + | {name: "concurrent update", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true}, | |
| 92 | + | {name: "not found", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 93 | + | } | |
| 94 | + | for _, tt := range tests { | |
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | mrs := mocks.NewMergeRequestService(t) | |
| 98 | + | if tt.wantCall { | |
| 99 | + | mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything). | |
| 100 | + | Return(domain.Comment{ID: 5}, tt.usecase).Once() | |
| 101 | + | } | |
| 102 | + | resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{ | |
| 103 | + | Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi", | |
| 104 | + | })) | |
| 105 | + | if tt.wantCode != 0 { | |
| 106 | + | requireCode(t, tt.wantCode, err) | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | assert.Equal(t, "5", resp.Msg.GetComment().GetId()) | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 117 | + | for _, id := range []string{"", "abc", "-1", "0", "007", "99999999999999999999"} { | |
| 118 | + | t.Run(id, func(t *testing.T) { | |
| 119 | + | t.Parallel() | |
| 120 | + | mrs := mocks.NewMergeRequestService(t) | |
| 121 | + | mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true). | |
| 122 | + | Return(domain.Comment{}, domain.ErrNotFound).Once() | |
| 123 | + | _, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{ | |
| 124 | + | Repo: "demo", Number: 1, CommentId: id, Resolved: true, | |
| 125 | + | })) | |
| 126 | + | requireCode(t, connect.CodeNotFound, err) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestMergeRequestServiceMerge(t *testing.T) { | |
| 132 | + | t.Parallel() | |
| 133 | + | const head = "0123456789abcdef0123456789abcdef01234567" | |
| 134 | + | tests := []struct { | |
| 135 | + | name string | |
| 136 | + | req *pb.MergeMergeRequestRequest | |
| 137 | + | usecase error | |
| 138 | + | wantCode connect.Code | |
| 139 | + | wantCall bool | |
| 140 | + | }{ | |
| 141 | + | {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true}, | |
| 142 | + | {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument}, | |
| 143 | + | {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument}, | |
| 144 | + | {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true}, | |
| 145 | + | {name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true}, | |
| 146 | + | {name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 147 | + | } | |
| 148 | + | for _, tt := range tests { | |
| 149 | + | t.Run(tt.name, func(t *testing.T) { | |
| 150 | + | t.Parallel() | |
| 151 | + | mrs := mocks.NewMergeRequestService(t) | |
| 152 | + | if tt.wantCall { | |
| 153 | + | mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool { | |
| 154 | + | return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero() | |
| 155 | + | })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once() | |
| 156 | + | } | |
| 157 | + | resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req)) | |
| 158 | + | if tt.wantCode != 0 { | |
| 159 | + | requireCode(t, tt.wantCode, err) | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | assert.True(t, resp.Msg.GetSourceBranchDeleted()) | |
| 164 | + | }) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) { | |
| 169 | + | t.Parallel() | |
| 170 | + | t.Run("close not found", func(t *testing.T) { | |
| 171 | + | t.Parallel() | |
| 172 | + | mrs := mocks.NewMergeRequestService(t) | |
| 173 | + | mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once() | |
| 174 | + | _, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9})) | |
| 175 | + | requireCode(t, connect.CodeNotFound, err) | |
| 176 | + | }) | |
| 177 | + | t.Run("close ok", func(t *testing.T) { | |
| 178 | + | t.Parallel() | |
| 179 | + | mrs := mocks.NewMergeRequestService(t) | |
| 180 | + | mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once() | |
| 181 | + | resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9})) | |
| 182 | + | require.NoError(t, err) | |
| 183 | + | assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState()) | |
| 184 | + | }) | |
| 185 | + | t.Run("mark read rejects a short head", func(t *testing.T) { | |
| 186 | + | t.Parallel() | |
| 187 | + | _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(), | |
| 188 | + | connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"})) | |
| 189 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 190 | + | }) | |
| 191 | + | t.Run("mark read unauthenticated", func(t *testing.T) { | |
| 192 | + | t.Parallel() | |
| 193 | + | mrs := mocks.NewMergeRequestService(t) | |
| 194 | + | mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once() | |
| 195 | + | _, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1})) | |
| 196 | + | requireCode(t, connect.CodeUnauthenticated, err) | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestMergeRequestServiceGet(t *testing.T) { | |
| 201 | + | t.Parallel() | |
| 202 | + | tests := []struct { | |
| 203 | + | name string | |
| 204 | + | usecase error | |
| 205 | + | wantCode connect.Code | |
| 206 | + | }{ | |
| 207 | + | {name: "ok"}, | |
| 208 | + | {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |
| 209 | + | {name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 210 | + | } | |
| 211 | + | for _, tt := range tests { | |
| 212 | + | t.Run(tt.name, func(t *testing.T) { | |
| 213 | + | t.Parallel() | |
| 214 | + | mrs := mocks.NewMergeRequestService(t) | |
| 215 | + | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true). | |
| 216 | + | Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once() | |
| 217 | + | resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true})) | |
| 218 | + | if tt.wantCode != 0 { | |
| 219 | + | requireCode(t, tt.wantCode, err) | |
| 220 | + | return | |
| 221 | + | } | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber()) | |
| 224 | + | assert.True(t, resp.Msg.GetCommitsTruncated()) | |
| 225 | + | }) | |
| 226 | + | } | |
| 227 | + | } | |
M internal/adapters/rpc/repo.go +6 -6
| @@ -9,12 +9,12 @@ import ( | |||
| 9 | 9 | "connectrpc.com/connect" | |
| 10 | 10 | ||
| 11 | 11 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | - | "bnema.dev/ermite/internal/app" | |
| 13 | 12 | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // repoService implements ermiteconnect.RepoServiceHandler. | |
| 17 | - | type repoService struct{ repos *app.Repos } | |
| 17 | + | type repoService struct{ repos ports.RepoService } | |
| 18 | 18 | ||
| 19 | 19 | func (s *repoService) ListRepos(ctx context.Context, req *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |
| 20 | 20 | page, err := s.repos.ListPage(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken()) | |
| @@ -53,7 +53,7 @@ func (s *repoService) CreateRepo(ctx context.Context, req *connect.Request[pb.Cr | |||
| 53 | 53 | if err != nil { | |
| 54 | 54 | return nil, connectError(ctx, err) | |
| 55 | 55 | } | |
| 56 | - | repo, err := s.repos.Create(ctx, caller(ctx), app.CreateParams{ | |
| 56 | + | repo, err := s.repos.Create(ctx, caller(ctx), ports.CreateRepoParams{ | |
| 57 | 57 | Name: name, Description: req.Msg.GetDescription(), Visibility: vis, | |
| 58 | 58 | }) | |
| 59 | 59 | if err != nil { | |
| @@ -84,8 +84,8 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||
| 84 | 84 | // nothing else. | |
| 85 | 85 | // | |
| 86 | 86 | //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 | |
| 87 | - | func updateParams(msg *pb.UpdateRepoRequest) (app.UpdateParams, error) { | |
| 88 | - | var params app.UpdateParams | |
| 87 | + | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| 88 | + | var params ports.UpdateRepoParams | |
| 89 | 89 | paths := msg.GetUpdateMask().GetPaths() | |
| 90 | 90 | if len(paths) == 0 { | |
| 91 | 91 | return params, fmt.Errorf("%w: update_mask is empty", domain.ErrInvalid) | |
| @@ -135,7 +135,7 @@ func (s *repoService) DeleteRepo(ctx context.Context, req *connect.Request[pb.De | |||
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | 137 | // respondRepo adds the default branch to repo and wraps it in a response. | |
| 138 | - | func respondRepo[T any](ctx context.Context, repos *app.Repos, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) { | |
| 138 | + | func respondRepo[T any](ctx context.Context, repos ports.RepoService, repo domain.Repo, wrap func(*pb.Repo) *T) (*connect.Response[T], error) { | |
| 139 | 139 | view, err := repos.View(ctx, repo) | |
| 140 | 140 | if err != nil { | |
| 141 | 141 | return nil, connectError(ctx, err) | |
A internal/adapters/rpc/repo_test.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 15 | + | ||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // ownerCtx is the context of a call made by the owner, as the interceptor | |
| 23 | + | // leaves it. | |
| 24 | + | func ownerCtx() context.Context { | |
| 25 | + | return domain.ContextWithPrincipal(context.Background(), domain.Owner()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func mustRepoName(t *testing.T, s string) domain.RepoName { | |
| 29 | + | t.Helper() | |
| 30 | + | n, err := domain.ParseRepoName(s) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | return n | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func requireCode(t *testing.T, want connect.Code, err error) { | |
| 36 | + | t.Helper() | |
| 37 | + | require.Error(t, err) | |
| 38 | + | assert.Equal(t, want, connect.CodeOf(err), err.Error()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestRepoServiceGetRepo(t *testing.T) { | |
| 42 | + | t.Parallel() | |
| 43 | + | boom := errors.New("disk on fire") | |
| 44 | + | tests := []struct { | |
| 45 | + | name string | |
| 46 | + | repo string | |
| 47 | + | usecase error | |
| 48 | + | viewErr error | |
| 49 | + | wantCode connect.Code | |
| 50 | + | wantCall bool | |
| 51 | + | }{ | |
| 52 | + | {name: "ok", repo: "demo", wantCall: true}, | |
| 53 | + | {name: "invalid name never reaches the use case", repo: "../etc", wantCode: connect.CodeInvalidArgument}, | |
| 54 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 55 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 56 | + | {name: "unauthenticated", repo: "demo", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true}, | |
| 57 | + | {name: "unknown error is internal and opaque", repo: "demo", usecase: boom, wantCode: connect.CodeInternal, wantCall: true}, | |
| 58 | + | {name: "view failure", repo: "demo", viewErr: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | repos := mocks.NewRepoService(t) | |
| 64 | + | if tt.wantCall { | |
| 65 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "d", Visibility: domain.VisibilityPublic} | |
| 66 | + | repos.EXPECT().Get(mock.Anything, domain.Owner(), repo.Name).Return(repo, tt.usecase).Once() | |
| 67 | + | if tt.usecase == nil { | |
| 68 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo, DefaultBranch: "main"}, tt.viewErr).Once() | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | svc := &repoService{repos} | |
| 72 | + | resp, err := svc.GetRepo(ownerCtx(), connect.NewRequest(&pb.GetRepoRequest{Repo: tt.repo})) | |
| 73 | + | if tt.wantCode != 0 { | |
| 74 | + | requireCode(t, tt.wantCode, err) | |
| 75 | + | if errors.Is(tt.usecase, boom) { | |
| 76 | + | assert.NotContains(t, err.Error(), "disk on fire") | |
| 77 | + | } | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | assert.Equal(t, "demo", resp.Msg.GetRepo().GetName()) | |
| 82 | + | assert.Equal(t, "main", resp.Msg.GetRepo().GetDefaultBranch()) | |
| 83 | + | }) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestRepoServiceCreateRepo(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 89 | + | tests := []struct { | |
| 90 | + | name string | |
| 91 | + | req *pb.CreateRepoRequest | |
| 92 | + | usecase error | |
| 93 | + | wantCode connect.Code | |
| 94 | + | wantCall bool | |
| 95 | + | }{ | |
| 96 | + | {name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true}, | |
| 97 | + | {name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument}, | |
| 98 | + | {name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument}, | |
| 99 | + | {name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true}, | |
| 100 | + | {name: "forbidden", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 101 | + | } | |
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | t.Parallel() | |
| 105 | + | repos := mocks.NewRepoService(t) | |
| 106 | + | if tt.wantCall { | |
| 107 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate} | |
| 108 | + | repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool { | |
| 109 | + | return p.Name.String() == "demo" | |
| 110 | + | })).Return(repo, tt.usecase).Once() | |
| 111 | + | if tt.usecase == nil { | |
| 112 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once() | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | resp, err := (&repoService{repos}).CreateRepo(ownerCtx(), connect.NewRequest(tt.req)) | |
| 116 | + | if tt.wantCode != 0 { | |
| 117 | + | requireCode(t, tt.wantCode, err) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | assert.Equal(t, "demo", resp.Msg.GetRepo().GetName()) | |
| 122 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, resp.Msg.GetRepo().GetVisibility()) | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestRepoServiceUpdateRepoValidatesMask(t *testing.T) { | |
| 128 | + | t.Parallel() | |
| 129 | + | tests := []struct { | |
| 130 | + | name string | |
| 131 | + | req *pb.UpdateRepoRequest | |
| 132 | + | usecase error | |
| 133 | + | wantCode connect.Code | |
| 134 | + | wantCall bool | |
| 135 | + | }{ | |
| 136 | + | {name: "empty mask", req: &pb.UpdateRepoRequest{Repo: "demo"}, wantCode: connect.CodeInvalidArgument}, | |
| 137 | + | { | |
| 138 | + | name: "unknown path", | |
| 139 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}}, | |
| 140 | + | wantCode: connect.CodeInvalidArgument, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "unspecified visibility", | |
| 144 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}}, | |
| 145 | + | wantCode: connect.CodeInvalidArgument, | |
| 146 | + | }, | |
| 147 | + | { | |
| 148 | + | name: "invalid repo name", | |
| 149 | + | req: &pb.UpdateRepoRequest{Repo: "", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}}, | |
| 150 | + | wantCode: connect.CodeInvalidArgument, | |
| 151 | + | }, | |
| 152 | + | { | |
| 153 | + | name: "not found", | |
| 154 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}}, | |
| 155 | + | usecase: domain.ErrNotFound, | |
| 156 | + | wantCode: connect.CodeNotFound, wantCall: true, | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "precondition failed", | |
| 160 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"default_branch"}}, DefaultBranch: "nope"}, | |
| 161 | + | usecase: domain.ErrPreconditionFailed, | |
| 162 | + | wantCode: connect.CodeFailedPrecondition, wantCall: true, | |
| 163 | + | }, | |
| 164 | + | { | |
| 165 | + | name: "ok", | |
| 166 | + | req: &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, Description: "new"}, | |
| 167 | + | wantCall: true, | |
| 168 | + | }, | |
| 169 | + | } | |
| 170 | + | for _, tt := range tests { | |
| 171 | + | t.Run(tt.name, func(t *testing.T) { | |
| 172 | + | t.Parallel() | |
| 173 | + | repos := mocks.NewRepoService(t) | |
| 174 | + | if tt.wantCall { | |
| 175 | + | repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "new"} | |
| 176 | + | repos.EXPECT().Update(mock.Anything, domain.Owner(), repo.Name, mock.Anything).Return(repo, tt.usecase).Once() | |
| 177 | + | if tt.usecase == nil { | |
| 178 | + | repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once() | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | resp, err := (&repoService{repos}).UpdateRepo(ownerCtx(), connect.NewRequest(tt.req)) | |
| 182 | + | if tt.wantCode != 0 { | |
| 183 | + | requireCode(t, tt.wantCode, err) | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | assert.Equal(t, "new", resp.Msg.GetRepo().GetDescription()) | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func TestRepoServiceDeleteRepo(t *testing.T) { | |
| 193 | + | t.Parallel() | |
| 194 | + | tests := []struct { | |
| 195 | + | name string | |
| 196 | + | repo string | |
| 197 | + | usecase error | |
| 198 | + | wantCode connect.Code | |
| 199 | + | wantCall bool | |
| 200 | + | }{ | |
| 201 | + | {name: "ok", repo: "demo", wantCall: true}, | |
| 202 | + | {name: "invalid name", repo: "a/../b", wantCode: connect.CodeInvalidArgument}, | |
| 203 | + | {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true}, | |
| 204 | + | {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 205 | + | } | |
| 206 | + | for _, tt := range tests { | |
| 207 | + | t.Run(tt.name, func(t *testing.T) { | |
| 208 | + | t.Parallel() | |
| 209 | + | repos := mocks.NewRepoService(t) | |
| 210 | + | if tt.wantCall { | |
| 211 | + | repos.EXPECT().Delete(mock.Anything, domain.Owner(), mustRepoName(t, tt.repo)).Return(tt.usecase).Once() | |
| 212 | + | } | |
| 213 | + | _, err := (&repoService{repos}).DeleteRepo(ownerCtx(), connect.NewRequest(&pb.DeleteRepoRequest{Repo: tt.repo})) | |
| 214 | + | if tt.wantCode != 0 { | |
| 215 | + | requireCode(t, tt.wantCode, err) | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | }) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func TestRepoServiceListRepos(t *testing.T) { | |
| 224 | + | t.Parallel() | |
| 225 | + | t.Run("pages and views every repo", func(t *testing.T) { | |
| 226 | + | t.Parallel() | |
| 227 | + | repos := mocks.NewRepoService(t) | |
| 228 | + | a := domain.Repo{Name: mustRepoName(t, "a")} | |
| 229 | + | b := domain.Repo{Name: mustRepoName(t, "b")} | |
| 230 | + | repos.EXPECT().ListPage(mock.Anything, domain.Owner(), 2, "tok").Return(ports.RepoPage{Repos: []domain.Repo{a, b}, NextPageToken: "next"}, nil).Once() | |
| 231 | + | repos.EXPECT().View(mock.Anything, a).Return(ports.RepoView{Repo: a}, nil).Once() | |
| 232 | + | repos.EXPECT().View(mock.Anything, b).Return(ports.RepoView{Repo: b}, nil).Once() | |
| 233 | + | resp, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{PageSize: 2, PageToken: "tok"})) | |
| 234 | + | require.NoError(t, err) | |
| 235 | + | require.Len(t, resp.Msg.GetRepos(), 2) | |
| 236 | + | assert.Equal(t, "next", resp.Msg.GetNextPageToken()) | |
| 237 | + | }) | |
| 238 | + | for name, tc := range map[string]struct { | |
| 239 | + | err error | |
| 240 | + | code connect.Code | |
| 241 | + | }{ | |
| 242 | + | "bad page token": {domain.ErrInvalidPageToken, connect.CodeInvalidArgument}, | |
| 243 | + | "bad page size": {domain.ErrInvalidPageSize, connect.CodeInvalidArgument}, | |
| 244 | + | "unauthenticated": {domain.ErrUnauthenticated, connect.CodeUnauthenticated}, | |
| 245 | + | } { | |
| 246 | + | t.Run(name, func(t *testing.T) { | |
| 247 | + | t.Parallel() | |
| 248 | + | repos := mocks.NewRepoService(t) | |
| 249 | + | repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, tc.err).Once() | |
| 250 | + | _, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{})) | |
| 251 | + | requireCode(t, tc.code, err) | |
| 252 | + | }) | |
| 253 | + | } | |
| 254 | + | } | |
M internal/adapters/rpc/server.go +2 -2
| @@ -9,13 +9,13 @@ import ( | |||
| 9 | 9 | "connectrpc.com/connect" | |
| 10 | 10 | ||
| 11 | 11 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | - | "bnema.dev/ermite/internal/app" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // serverService implements ermiteconnect.ServerServiceHandler. | |
| 16 | 16 | type serverService struct { | |
| 17 | 17 | version string | |
| 18 | - | key *app.SigningKey | |
| 18 | + | key ports.SigningKeyService | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | func (s *serverService) GetVersion(context.Context, *connect.Request[pb.GetVersionRequest]) (*connect.Response[pb.GetVersionResponse], error) { | |
A internal/adapters/rpc/server_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestServerServiceGetVersion(t *testing.T) { | |
| 19 | + | t.Parallel() | |
| 20 | + | svc := &serverService{version: "v1.2.3", key: mocks.NewSigningKeyService(t)} | |
| 21 | + | resp, err := svc.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | assert.Equal(t, "v1.2.3", resp.Msg.GetVersion()) | |
| 24 | + | assert.Equal(t, Features, resp.Msg.GetFeatures()) | |
| 25 | + | ||
| 26 | + | // The caller gets a copy: changing it does not change what is advertised. | |
| 27 | + | resp.Msg.Features[0] = "tampered" | |
| 28 | + | assert.NotEqual(t, "tampered", Features[0]) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestServerServiceGetSigningKey(t *testing.T) { | |
| 32 | + | t.Parallel() | |
| 33 | + | key := mocks.NewSigningKeyService(t) | |
| 34 | + | key.EXPECT().Info().Return(domain.SigningKeyInfo{ | |
| 35 | + | Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA", Fingerprint: "SHA256:xyz"}, | |
| 36 | + | Committer: domain.Identity{Name: "ermite", Email: "ermite@example.com"}, | |
| 37 | + | }).Once() | |
| 38 | + | resp, err := (&serverService{key: key}).GetSigningKey(context.Background(), connect.NewRequest(&pb.GetSigningKeyRequest{})) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, "ssh-ed25519 AAAA", resp.Msg.GetPublicKey()) | |
| 41 | + | assert.Equal(t, "SHA256:xyz", resp.Msg.GetFingerprint()) | |
| 42 | + | assert.Equal(t, "ermite", resp.Msg.GetCommitterName()) | |
| 43 | + | assert.Equal(t, "ermite@example.com", resp.Msg.GetCommitterEmail()) | |
| 44 | + | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | |
| 45 | + | } | |
M internal/adapters/rpc/session.go +2 -2
| @@ -8,12 +8,12 @@ import ( | |||
| 8 | 8 | "connectrpc.com/connect" | |
| 9 | 9 | ||
| 10 | 10 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | - | "bnema.dev/ermite/internal/app" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |
| 15 | 15 | // never calls it: sessions come from the link, and the API ignores cookies. | |
| 16 | - | type sessionService struct{ sessions *app.Sessions } | |
| 16 | + | type sessionService struct{ sessions ports.SessionService } | |
| 17 | 17 | ||
| 18 | 18 | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | |
| 19 | 19 | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | |
A internal/adapters/rpc/session_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestSessionServiceCreateLoginLink(t *testing.T) { | |
| 21 | + | t.Parallel() | |
| 22 | + | exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 23 | + | tests := []struct { | |
| 24 | + | name string | |
| 25 | + | usecase error | |
| 26 | + | wantCode connect.Code | |
| 27 | + | }{ | |
| 28 | + | {name: "ok"}, | |
| 29 | + | {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 30 | + | {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated}, | |
| 31 | + | } | |
| 32 | + | for _, tt := range tests { | |
| 33 | + | t.Run(tt.name, func(t *testing.T) { | |
| 34 | + | t.Parallel() | |
| 35 | + | sessions := mocks.NewSessionService(t) | |
| 36 | + | sessions.EXPECT().CreateLoginLink(mock.Anything, domain.Owner()). | |
| 37 | + | Return(ports.LoginLink{URL: "https://x/-/login/n", ExpiresAt: exp}, tt.usecase).Once() | |
| 38 | + | resp, err := (&sessionService{sessions}).CreateLoginLink(ownerCtx(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 39 | + | if tt.wantCode != 0 { | |
| 40 | + | requireCode(t, tt.wantCode, err) | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | assert.Equal(t, "https://x/-/login/n", resp.Msg.GetUrl()) | |
| 45 | + | assert.True(t, exp.Equal(resp.Msg.GetExpiresAt().AsTime())) | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSessionServiceSignOutEverywhere(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 52 | + | tests := []struct { | |
| 53 | + | name string | |
| 54 | + | usecase error | |
| 55 | + | wantCode connect.Code | |
| 56 | + | }{ | |
| 57 | + | {name: "ok"}, | |
| 58 | + | {name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | sessions := mocks.NewSessionService(t) | |
| 64 | + | sessions.EXPECT().SignOutEverywhere(mock.Anything, domain.Owner()).Return(tt.usecase).Once() | |
| 65 | + | _, err := (&sessionService{sessions}).SignOutEverywhere(ownerCtx(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 66 | + | if tt.wantCode != 0 { | |
| 67 | + | requireCode(t, tt.wantCode, err) | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
M internal/adapters/rpc/token.go +3 -3
| @@ -8,15 +8,15 @@ import ( | |||
| 8 | 8 | "connectrpc.com/connect" | |
| 9 | 9 | ||
| 10 | 10 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | - | "bnema.dev/ermite/internal/app" | |
| 12 | 11 | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // tokenService implements ermiteconnect.TokenServiceHandler. | |
| 16 | - | type tokenService struct{ tokens *app.Tokens } | |
| 16 | + | type tokenService struct{ tokens ports.TokenService } | |
| 17 | 17 | ||
| 18 | 18 | func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.CreateTokenRequest]) (*connect.Response[pb.CreateTokenResponse], error) { | |
| 19 | - | params := app.CreateTokenParams{ | |
| 19 | + | params := ports.CreateTokenParams{ | |
| 20 | 20 | Label: req.Msg.GetLabel(), | |
| 21 | 21 | ExpiresAt: goTime(req.Msg.GetExpiresAt()), | |
| 22 | 22 | } | |
A internal/adapters/rpc/token_test.go +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func TestTokenServiceCreateToken(t *testing.T) { | |
| 20 | + | t.Parallel() | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | req *pb.CreateTokenRequest | |
| 24 | + | usecase error | |
| 25 | + | wantCode connect.Code | |
| 26 | + | wantCall bool | |
| 27 | + | }{ | |
| 28 | + | {name: "ok", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"}}, wantCall: true}, | |
| 29 | + | {name: "unspecified scope", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_UNSPECIFIED}}, wantCode: connect.CodeInvalidArgument}, | |
| 30 | + | {name: "invalid repo name", req: &pb.CreateTokenRequest{Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"../x"}}, wantCode: connect.CodeInvalidArgument}, | |
| 31 | + | {name: "forbidden", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true}, | |
| 32 | + | {name: "anonymous", req: &pb.CreateTokenRequest{Label: "ci"}, usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true}, | |
| 33 | + | {name: "invalid fields", req: &pb.CreateTokenRequest{}, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true}, | |
| 34 | + | } | |
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | t.Parallel() | |
| 38 | + | tokens := mocks.NewTokenService(t) | |
| 39 | + | if tt.wantCall { | |
| 40 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything). | |
| 41 | + | Return(ports.CreatedToken{Token: domain.Token{ID: "t1", Label: "ci"}, Secret: "s3cret"}, tt.usecase).Once() | |
| 42 | + | } | |
| 43 | + | resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(tt.req)) | |
| 44 | + | if tt.wantCode != 0 { | |
| 45 | + | requireCode(t, tt.wantCode, err) | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, "s3cret", resp.Msg.GetSecret()) | |
| 50 | + | assert.Equal(t, "t1", resp.Msg.GetToken().GetId()) | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestTokenServiceCreateTokenPassesParams(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | tokens := mocks.NewTokenService(t) | |
| 58 | + | tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool { | |
| 59 | + | return p.Label == "ci" && len(p.Scopes) == 1 && len(p.Repos) == 1 && p.Repos[0].String() == "demo" | |
| 60 | + | })).Return(ports.CreatedToken{}, nil).Once() | |
| 61 | + | _, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{ | |
| 62 | + | Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_READ}, Repos: []string{"demo"}, | |
| 63 | + | })) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestTokenServiceRevokeToken(t *testing.T) { | |
| 68 | + | t.Parallel() | |
| 69 | + | tests := []struct { | |
| 70 | + | name string | |
| 71 | + | usecase error | |
| 72 | + | wantCode connect.Code | |
| 73 | + | }{ | |
| 74 | + | {name: "ok"}, | |
| 75 | + | {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound}, | |
| 76 | + | {name: "forbidden", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied}, | |
| 77 | + | } | |
| 78 | + | for _, tt := range tests { | |
| 79 | + | t.Run(tt.name, func(t *testing.T) { | |
| 80 | + | t.Parallel() | |
| 81 | + | tokens := mocks.NewTokenService(t) | |
| 82 | + | tokens.EXPECT().Revoke(mock.Anything, domain.Owner(), "t1").Return(tt.usecase).Once() | |
| 83 | + | _, err := (&tokenService{tokens}).RevokeToken(ownerCtx(), connect.NewRequest(&pb.RevokeTokenRequest{Id: "t1"})) | |
| 84 | + | if tt.wantCode != 0 { | |
| 85 | + | requireCode(t, tt.wantCode, err) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestTokenServiceListTokens(t *testing.T) { | |
| 94 | + | t.Parallel() | |
| 95 | + | tokens := mocks.NewTokenService(t) | |
| 96 | + | tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 5, "tok"). | |
| 97 | + | Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a"}, {ID: "b"}}, NextPageToken: "n"}, nil).Once() | |
| 98 | + | resp, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{PageSize: 5, PageToken: "tok"})) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Len(t, resp.Msg.GetTokens(), 2) | |
| 101 | + | assert.Equal(t, "n", resp.Msg.GetNextPageToken()) | |
| 102 | + | ||
| 103 | + | tokens.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.TokenPage{}, domain.ErrInvalidPageToken).Once() | |
| 104 | + | _, err = (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{})) | |
| 105 | + | requireCode(t, connect.CodeInvalidArgument, err) | |
| 106 | + | } | |
M internal/adapters/web/ASSETS.md +3 -3
| @@ -8,18 +8,18 @@ content-hashed names. Nothing is fetched at runtime. | |||
| 8 | 8 | | `static/js/htmx.min.js` | htmx 4.0.0 (BSD Zero Clause, `static/js/htmx-LICENSE`) | `mockups/assets/htmx/htmx.min.js` of the design | `e484d9171a9db30a39c8f16e3d709d4137f3211c659f8e6125816635033d593f` | | |
| 9 | 9 | | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` | | |
| 10 | 10 | | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` | | |
| 11 | - | | `static/js/ermite.js` | key sequences to `k-*` events | `mockups/d/js/ermite.js` | | | |
| 11 | + | | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | | | |
| 12 | 12 | | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | | | |
| 13 | 13 | | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | | | |
| 14 | 14 | | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | | | |
| 15 | 15 | | `static/fonts/atkinson-next.woff2`, `atkinson-next-italic.woff2` | Atkinson Hyperlegible Next, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-next.txt`) | Google Fonts | `18b2a1a39a2fa298b0ba5390aca68462669826c90925656f1c1f6796e0e1bbaf`, `4a5037bfaf6680f40147407407ec09fa42925774bde809a579283d27f9f08106` | | |
| 16 | 16 | | `static/fonts/atkinson-mono.woff2` | Atkinson Hyperlegible Mono, variable weight, latin subset (SIL OFL 1.1, `static/fonts/OFL-mono.txt`) | Google Fonts | `2706b1ee4f452e744ea91f7e4908cbde9c5d35521bf5ffffc71a382a2de89613` | | |
| 17 | 17 | | `static/icons/*.svg` | interface icons, used as CSS masks; `brand.svg` is a grey picture shown as is | written here | | | |
| 18 | - | | `static/rules.json` | speculation rules: prefetch same-origin pages on hover (`Speculation-Rules` header) | written here | | | |
| 18 | + | | `static/rules.json` | speculation rules: prefetch the light same-origin pages on hover (`Speculation-Rules` header); the heavy ones (tree, blob, commit, commits, compare, refs, raw, MR "since") are excluded, each costs several Git reads on the server | written here | | | |
| 19 | 19 | ||
| 20 | 20 | The three htmx files are the exact files the approved mockup was built with. | |
| 21 | 21 | To upgrade, replace the file, update the checksum here, and run the web tests | |
| 22 | - | (`go test ./cmd/ermite -run TestWeb`). | |
| 22 | + | (`go test ./internal/app -run TestWeb`). | |
| 23 | 23 | ||
| 24 | 24 | `*_templ.go` files are generated by `go tool templ generate` (templ is pinned | |
| 25 | 25 | in `go.mod`) and committed; `scripts/templ-drift.sh` fails when they are stale. | |
M internal/adapters/web/assets.go +29 -3
| @@ -34,6 +34,9 @@ type Assets struct { | |||
| 34 | 34 | type asset struct { | |
| 35 | 35 | body []byte | |
| 36 | 36 | typ string | |
| 37 | + | // gz is the gzip form of body, compressed once at load; nil when the type | |
| 38 | + | // is already compressed or gzip does not make the body smaller. | |
| 39 | + | gz []byte | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | 42 | // assetSet is the embedded files by hashed URL path (under StaticPrefix). | |
| @@ -71,7 +74,13 @@ func loadAssets() (*assetSet, error) { | |||
| 71 | 74 | sum := sha256.Sum256(body) | |
| 72 | 75 | ext := path.Ext(file) | |
| 73 | 76 | name := dir + "/" + strings.TrimSuffix(file, ext) + "." + hex.EncodeToString(sum[:6]) + ext | |
| 74 | - | s.byName[name] = asset{body: body, typ: typ} | |
| 77 | + | a := asset{body: body, typ: typ} | |
| 78 | + | if compressible(typ) { | |
| 79 | + | if z, err := gzipBest(body); err == nil && len(z) < len(body) { | |
| 80 | + | a.gz = z | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | s.byName[name] = a | |
| 75 | 84 | return StaticPrefix + name | |
| 76 | 85 | } | |
| 77 | 86 | ||
| @@ -149,8 +158,25 @@ func (s *assetSet) serve(w http.ResponseWriter, r *http.Request) { | |||
| 149 | 158 | h := w.Header() | |
| 150 | 159 | h.Set("Content-Type", a.typ) | |
| 151 | 160 | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 152 | - | h.Set("Content-Length", itoa(len(a.body))) | |
| 161 | + | body := a.body | |
| 162 | + | if a.gz != nil { | |
| 163 | + | addVary(h, "Accept-Encoding") | |
| 164 | + | if acceptsGzip(r.Header) { | |
| 165 | + | body = a.gz | |
| 166 | + | h.Set("Content-Encoding", "gzip") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | h.Set("Content-Length", itoa(len(body))) | |
| 153 | 170 | if r.Method != http.MethodHead { | |
| 154 | - | _, _ = w.Write(a.body) | |
| 171 | + | _, _ = w.Write(body) | |
| 155 | 172 | } | |
| 156 | 173 | } | |
| 174 | + | ||
| 175 | + | // compressible tells whether a content type is worth gzip: text and | |
| 176 | + | // structured text. Fonts (woff2) and raster images are compressed already. | |
| 177 | + | func compressible(typ string) bool { | |
| 178 | + | mt, _, _ := strings.Cut(typ, ";") | |
| 179 | + | mt = strings.TrimSpace(strings.ToLower(mt)) | |
| 180 | + | return strings.HasPrefix(mt, "text/") || mt == "image/svg+xml" || | |
| 181 | + | mt == "application/javascript" || strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "/json") | |
| 182 | + | } | |
M internal/adapters/web/blob.go +38 -39
| @@ -4,17 +4,18 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "html/template" | |
| 10 | + | "io" | |
| 9 | 11 | "net/http" | |
| 10 | 12 | "path" | |
| 11 | 13 | "strings" | |
| 12 | 14 | ||
| 13 | - | "connectrpc.com/connect" | |
| 14 | 15 | "github.com/a-h/templ" | |
| 15 | 16 | "github.com/bnema/zerowrap" | |
| 16 | 17 | ||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | const ( | |
| @@ -45,43 +46,43 @@ type BlobPage struct { | |||
| 45 | 46 | DirHref string | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | - | // blobStream is an open GetBlob stream whose first message has been read. | |
| 49 | + | // blobStream is an open file: its metadata and the content, read on demand. | |
| 49 | 50 | type blobStream struct { | |
| 50 | - | stream *connect.ServerStreamForClient[pb.GetBlobResponse] | |
| 51 | - | info *pb.BlobInfo | |
| 52 | - | // buf holds the content already received and not yet handed out. | |
| 51 | + | body io.ReadCloser | |
| 52 | + | info domain.BlobInfo | |
| 53 | + | // buf holds the content already read and not yet handed out. | |
| 53 | 54 | buf []byte | |
| 55 | + | // eof is true once body has nothing more. | |
| 56 | + | eof bool | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | - | // openBlob starts the stream of a file and reads its metadata. | |
| 57 | - | func (s *Server) openBlob(ctx context.Context, repo string, t target) (*blobStream, error) { | |
| 58 | - | if t.path == "" { | |
| 59 | - | return nil, errNotFound | |
| 60 | - | } | |
| 61 | - | st, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: t.rev, Path: t.path})) | |
| 59 | + | // openBlob opens a file of the repository. | |
| 60 | + | func (s *Server) openBlob(ctx context.Context, repo domain.RepoName, t target) (*blobStream, error) { | |
| 61 | + | info, body, err := s.openBlobAt(ctx, repo, t.rev, t.path) | |
| 62 | 62 | if err != nil { | |
| 63 | 63 | return nil, err | |
| 64 | 64 | } | |
| 65 | - | if !st.Receive() { | |
| 66 | - | err := st.Err() | |
| 67 | - | _ = st.Close() | |
| 68 | - | if err == nil { | |
| 69 | - | err = errNotFound | |
| 70 | - | } | |
| 71 | - | return nil, err | |
| 72 | - | } | |
| 73 | - | m := st.Msg() | |
| 74 | - | return &blobStream{stream: st, info: m.GetInfo(), buf: append([]byte(nil), m.GetChunk()...)}, nil | |
| 65 | + | return &blobStream{body: body, info: info}, nil | |
| 75 | 66 | } | |
| 76 | 67 | ||
| 77 | - | func (b *blobStream) close() { _ = b.stream.Close() } | |
| 68 | + | func (b *blobStream) close() { _ = b.body.Close() } | |
| 78 | 69 | ||
| 79 | - | // fill reads from the stream until buf holds n bytes or the content ends. | |
| 70 | + | // fill reads until buf holds n bytes or the content ends. | |
| 80 | 71 | func (b *blobStream) fill(n int) error { | |
| 81 | - | for len(b.buf) < n && b.stream.Receive() { | |
| 82 | - | b.buf = append(b.buf, b.stream.Msg().GetChunk()...) | |
| 72 | + | if b.eof || len(b.buf) >= n { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | // One allocation for the whole content, not the doublings of append. | |
| 76 | + | if cap(b.buf) < n { | |
| 77 | + | b.buf = append(make([]byte, 0, n), b.buf...) | |
| 78 | + | } | |
| 79 | + | m, err := io.ReadFull(b.body, b.buf[len(b.buf):n]) | |
| 80 | + | b.buf = b.buf[:len(b.buf)+m] | |
| 81 | + | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { | |
| 82 | + | b.eof = true | |
| 83 | + | return nil | |
| 83 | 84 | } | |
| 84 | - | return b.stream.Err() | |
| 85 | + | return err | |
| 85 | 86 | } | |
| 86 | 87 | ||
| 87 | 88 | // image types: the only images the pages show and raw serves as images. | |
| @@ -110,7 +111,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 110 | 111 | if !ok { | |
| 111 | 112 | return nil, 0, errNotFound | |
| 112 | 113 | } | |
| 113 | - | b, err := s.openBlob(ctx, d.name(), t) | |
| 114 | + | b, err := s.openBlob(ctx, d.repo.Name, t) | |
| 114 | 115 | if err != nil { | |
| 115 | 116 | return nil, 0, err | |
| 116 | 117 | } | |
| @@ -121,7 +122,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 121 | 122 | if dir == "." { | |
| 122 | 123 | dir = "" | |
| 123 | 124 | } | |
| 124 | - | size := b.info.GetSize() | |
| 125 | + | size := b.info.Size | |
| 125 | 126 | p := BlobPage{ | |
| 126 | 127 | Name: visibleText(name), | |
| 127 | 128 | RawHref: repoHref(d.name(), "raw", t.name, t.path), | |
| @@ -129,7 +130,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 129 | 130 | DirHref: repoHref(d.name(), "tree", t.name, dir), | |
| 130 | 131 | Facts: []Fact{{Key: "size", Val: humanSize(size)}}, | |
| 131 | 132 | } | |
| 132 | - | typ := b.info.GetMediaType() | |
| 133 | + | typ := b.info.MediaType | |
| 133 | 134 | lang := "" | |
| 134 | 135 | ||
| 135 | 136 | // An image is recognised by its bytes, whatever Git's text rule says. | |
| @@ -142,7 +143,7 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 142 | 143 | switch { | |
| 143 | 144 | case img != "": | |
| 144 | 145 | p.Image, typ = true, img | |
| 145 | - | case b.info.GetBinary(): | |
| 146 | + | case b.info.Binary: | |
| 146 | 147 | p.Notice = fmt.Sprintf("binary file (%s, %s): not shown. use raw.", typ, humanSize(size)) | |
| 147 | 148 | case size > maxCodeBytes: | |
| 148 | 149 | p.Notice = fmt.Sprintf("file too large to show (%s, the limit is %s). use raw.", humanSize(size), humanSize(maxCodeBytes)) | |
| @@ -180,14 +181,14 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 180 | 181 | } | |
| 181 | 182 | p.Facts = append(p.Facts, Fact{Key: "type", Val: lang}) | |
| 182 | 183 | ||
| 183 | - | if last := s.lastChange(ctx, d.name(), t); last != nil { | |
| 184 | + | if last := s.lastChange(ctx, d.repo.Name, t); last != nil { | |
| 184 | 185 | p.Last = last | |
| 185 | 186 | } | |
| 186 | 187 | ||
| 187 | 188 | l := s.repoLayout(ctx, d, "tree") | |
| 188 | 189 | // A blob stream does not name its commit: an abbreviated id is resolved | |
| 189 | 190 | // for the finder. | |
| 190 | - | s.resolveCommit(ctx, d.name(), &t) | |
| 191 | + | s.resolveCommit(ctx, d.repo.Name, &t) | |
| 191 | 192 | pinRef(&l, t) | |
| 192 | 193 | l.Crumbs = pathCrumbs(d.name(), "blob", t) | |
| 193 | 194 | l.Title = visibleText(t.path) + " · " + d.name() + " · " + s.instance | |
| @@ -197,14 +198,12 @@ func (s *Server) blob(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 197 | 198 | ||
| 198 | 199 | // lastChange is the latest commit that touched the path, nil when it cannot | |
| 199 | 200 | // be found (the page does not depend on it). | |
| 200 | - | func (s *Server) lastChange(ctx context.Context, repo string, t target) *CommitRow { | |
| 201 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 202 | - | Repo: repo, Revision: t.rev, Path: t.path, PageSize: 1, | |
| 203 | - | })) | |
| 204 | - | if err != nil || len(res.Msg.GetCommits()) != 1 { | |
| 201 | + | func (s *Server) lastChange(ctx context.Context, repo domain.RepoName, t target) *CommitRow { | |
| 202 | + | res, err := s.commitList(ctx, repo, t.rev, t.path, 1, "") | |
| 203 | + | if err != nil || len(res.Commits) != 1 { | |
| 205 | 204 | return nil | |
| 206 | 205 | } | |
| 207 | - | row := commitRow(repo, res.Msg.GetCommits()[0], s.cfg.Now()) | |
| 206 | + | row := commitRow(repo.String(), res.Commits[0], s.cfg.Now()) | |
| 208 | 207 | return &row | |
| 209 | 208 | } | |
| 210 | 209 | ||
M internal/adapters/web/blob.templ +1 -1
| @@ -54,7 +54,7 @@ templ CodeView(h template.HTML) { | |||
| 54 | 54 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" | |
| 55 | 55 | hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" | |
| 56 | 56 | data-ready="" | |
| 57 | - | ><code>@templ.Raw(string(h))</code></pre> | |
| 57 | + | ><code hx-ignore>@templ.Raw(string(h))</code></pre> | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | templ blobRail(p BlobPage) { | |
M internal/adapters/web/blob_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/browse.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // The reads of the pages go through BrowseService as the caller of the | |
| 14 | + | // request. The revisions and paths the URLs carry are text: they are parsed | |
| 15 | + | // here, and one the domain refuses is an invalid argument, which the pages | |
| 16 | + | // answer like a missing one (fail). | |
| 17 | + | ||
| 18 | + | // commitList reads a page of the history that starts at rev, limited to path | |
| 19 | + | // when it is not empty. | |
| 20 | + | func (s *Server) commitList(ctx context.Context, repo domain.RepoName, rev, path string, size int, token string) (ports.HistoryPage, error) { | |
| 21 | + | r, err := domain.ParseRevision(rev) | |
| 22 | + | if err != nil { | |
| 23 | + | return ports.HistoryPage{}, err | |
| 24 | + | } | |
| 25 | + | p, err := domain.ParseTreePath(path) | |
| 26 | + | if err != nil { | |
| 27 | + | return ports.HistoryPage{}, err | |
| 28 | + | } | |
| 29 | + | return s.cfg.Browse.ListCommits(ctx, principal(ctx), repo, ports.ListCommitsParams{ | |
| 30 | + | Revision: r, Path: p, PageSize: size, PageToken: token, | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // getTree lists the directory path of the commit rev. | |
| 35 | + | func (s *Server) getTree(ctx context.Context, repo domain.RepoName, rev, path string, lastCommit bool) (domain.Tree, error) { | |
| 36 | + | r, err := domain.ParseRevision(rev) | |
| 37 | + | if err != nil { | |
| 38 | + | return domain.Tree{}, err | |
| 39 | + | } | |
| 40 | + | p, err := domain.ParseTreePath(path) | |
| 41 | + | if err != nil { | |
| 42 | + | return domain.Tree{}, err | |
| 43 | + | } | |
| 44 | + | return s.cfg.Browse.GetTree(ctx, principal(ctx), repo, r, p, lastCommit) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // getCommit reads a commit with its diff. | |
| 48 | + | func (s *Server) getCommit(ctx context.Context, repo domain.RepoName, rev string) (domain.CommitDetail, error) { | |
| 49 | + | r, err := domain.ParseRevision(rev) | |
| 50 | + | if err != nil { | |
| 51 | + | return domain.CommitDetail{}, err | |
| 52 | + | } | |
| 53 | + | return s.cfg.Browse.GetCommit(ctx, principal(ctx), repo, r) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // openBlobAt opens the file path of the commit rev. The caller closes the | |
| 57 | + | // body. | |
| 58 | + | func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path string) (domain.BlobInfo, io.ReadCloser, error) { | |
| 59 | + | r, err := domain.ParseRevision(rev) | |
| 60 | + | if err != nil { | |
| 61 | + | return domain.BlobInfo{}, nil, err | |
| 62 | + | } | |
| 63 | + | if path == "" { | |
| 64 | + | return domain.BlobInfo{}, nil, errNotFound | |
| 65 | + | } | |
| 66 | + | p, err := domain.ParseTreePath(path) | |
| 67 | + | if err != nil { | |
| 68 | + | return domain.BlobInfo{}, nil, err | |
| 69 | + | } | |
| 70 | + | return s.cfg.Browse.OpenBlob(ctx, principal(ctx), repo, r, p) | |
| 71 | + | } | |
| 72 | + | ||
| 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) { | |
| 77 | + | r, err := domain.ParseRevision(rev) | |
| 78 | + | if err != nil { | |
| 79 | + | return domain.ResolvedRevision{}, err | |
| 80 | + | } | |
| 81 | + | return s.cfg.Browse.ResolveRevision(ctx, principal(ctx), repo, r) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // listPaths lists the file paths of the commit rev. | |
| 85 | + | func (s *Server) listPaths(ctx context.Context, repo domain.RepoName, rev string) (domain.PathList, error) { | |
| 86 | + | r, err := domain.ParseRevision(rev) | |
| 87 | + | if err != nil { | |
| 88 | + | return domain.PathList{}, err | |
| 89 | + | } | |
| 90 | + | return s.cfg.Browse.ListPaths(ctx, principal(ctx), repo, r) | |
| 91 | + | } | |
M internal/adapters/web/commit.go +38 -39
| @@ -9,10 +9,9 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | - | "connectrpc.com/connect" | |
| 13 | 12 | "github.com/a-h/templ" | |
| 14 | 13 | ||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | 15 | ) | |
| 17 | 16 | ||
| 18 | 17 | // bothViewsLines is the largest diff, in lines, that is sent in both the | |
| @@ -91,6 +90,8 @@ func diffLines(files []DiffFile) (lines int, invisible bool) { | |||
| 91 | 90 | ||
| 92 | 91 | // commit shows a commit: its message, its totals, the changed files and the | |
| 93 | 92 | // diff against the first parent. ?view=split selects the split layout. | |
| 93 | + | // | |
| 94 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here | |
| 94 | 95 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 95 | 96 | sha := r.PathValue("sha") | |
| 96 | 97 | if !isCommitID(sha) { | |
| @@ -100,57 +101,55 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 100 | 101 | if err != nil { | |
| 101 | 102 | return nil, 0, err | |
| 102 | 103 | } | |
| 103 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha})) | |
| 104 | + | res, err := s.getCommit(ctx, d.repo.Name, sha) | |
| 104 | 105 | if err != nil { | |
| 105 | 106 | return nil, 0, err | |
| 106 | 107 | } | |
| 107 | - | c := res.Msg.GetCommit() | |
| 108 | - | if c == nil { | |
| 109 | - | return nil, 0, errNotFound | |
| 110 | - | } | |
| 111 | - | stats := res.Msg.GetStats() | |
| 108 | + | c := res.Commit | |
| 109 | + | cid := c.ID.String() | |
| 112 | 110 | now := s.cfg.Now() | |
| 113 | 111 | ||
| 114 | 112 | p := CommitPage{ | |
| 115 | - | SHA: c.GetId(), Short: short(c.GetId()), | |
| 116 | - | Subject: visibleText(c.GetSummary()), Body: commitBody(c), | |
| 117 | - | Author: person(c.GetAuthor(), now), | |
| 118 | - | Merge: len(c.GetParentIds()) > 1, | |
| 119 | - | Root: len(c.GetParentIds()) == 0, | |
| 120 | - | NFiles: int(stats.GetFilesChanged()), | |
| 121 | - | Adds: int(stats.GetAdditions()), | |
| 122 | - | Dels: int(stats.GetDeletions()), | |
| 123 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | |
| 124 | - | } | |
| 125 | - | if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() { | |
| 113 | + | SHA: cid, Short: short(cid), | |
| 114 | + | Subject: visibleText(c.Summary), Body: commitBody(c), | |
| 115 | + | Author: person(c.Author, now), | |
| 116 | + | Merge: len(c.Parents) > 1, | |
| 117 | + | Root: len(c.Parents) == 0, | |
| 118 | + | NFiles: res.Stats.FilesChanged, | |
| 119 | + | Adds: res.Stats.Additions, | |
| 120 | + | Dels: res.Stats.Deletions, | |
| 121 | + | Files: diffFiles(res.Diff.Files), | |
| 122 | + | } | |
| 123 | + | if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email { | |
| 126 | 124 | p.Committer = &cm | |
| 127 | 125 | } | |
| 128 | - | for _, id := range c.GetParentIds() { | |
| 126 | + | for _, pid := range c.Parents { | |
| 127 | + | id := pid.String() | |
| 129 | 128 | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | |
| 130 | 129 | } | |
| 131 | 130 | for _, b := range d.branches { | |
| 132 | - | if b.GetCommitId() == c.GetId() { | |
| 133 | - | p.Refs = append(p.Refs, b.GetShortName()) | |
| 131 | + | if b.CommitID.String() == cid { | |
| 132 | + | p.Refs = append(p.Refs, b.ShortName) | |
| 134 | 133 | } | |
| 135 | 134 | } | |
| 136 | 135 | for _, t := range d.tags { | |
| 137 | - | if t.GetCommitId() == c.GetId() { | |
| 138 | - | p.Refs = append(p.Refs, "tag: "+t.GetShortName()) | |
| 136 | + | if t.CommitID.String() == cid { | |
| 137 | + | p.Refs = append(p.Refs, "tag: "+t.ShortName) | |
| 139 | 138 | } | |
| 140 | 139 | } | |
| 141 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | |
| 140 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 142 | 141 | ||
| 143 | 142 | lines, invisible := diffLines(p.Files) | |
| 144 | 143 | p.Invisible = invisible | |
| 145 | - | base := repoHref(d.name(), "commit", c.GetId()) | |
| 144 | + | base := repoHref(d.name(), "commit", cid) | |
| 146 | 145 | p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split") | |
| 147 | 146 | ||
| 148 | 147 | l := s.repoLayout(ctx, d, "commits") | |
| 149 | 148 | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | |
| 150 | - | pinRef(&l, target{rev: c.GetId(), name: p.Short, detached: true}) | |
| 149 | + | pinRef(&l, target{rev: cid, name: p.Short, detached: true}) | |
| 151 | 150 | history := repoHref(d.name()) | |
| 152 | 151 | if d.head != nil { | |
| 153 | - | history = repoHref(d.name(), "commits", d.head.GetShortName()) | |
| 152 | + | history = repoHref(d.name(), "commits", d.head.ShortName) | |
| 154 | 153 | } | |
| 155 | 154 | l.Crumbs = []Crumb{ | |
| 156 | 155 | {Label: d.name(), Href: repoHref(d.name())}, | |
| @@ -162,25 +161,25 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, | |||
| 162 | 161 | } | |
| 163 | 162 | ||
| 164 | 163 | // person converts a signature. | |
| 165 | - | func person(s *pb.Signature, now time.Time) Person { | |
| 166 | - | p := Person{Name: visibleText(s.GetName())} | |
| 167 | - | p.When, p.WhenAt = whenOf(now, s.GetTime()) | |
| 168 | - | if t := s.GetTime(); t.IsValid() { | |
| 169 | - | p.Abs = absolute(t.AsTime()) | |
| 164 | + | func person(s domain.Signature, now time.Time) Person { | |
| 165 | + | p := Person{Name: visibleText(s.Name)} | |
| 166 | + | p.When, p.WhenAt = whenOf(now, s.Time) | |
| 167 | + | if !s.Time.IsZero() { | |
| 168 | + | p.Abs = absolute(s.Time) | |
| 170 | 169 | } | |
| 171 | 170 | return p | |
| 172 | 171 | } | |
| 173 | 172 | ||
| 174 | 173 | // commitBody is the message without its first line, for display. | |
| 175 | - | func commitBody(c *pb.Commit) string { | |
| 176 | - | msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n") | |
| 174 | + | func commitBody(c domain.Commit) string { | |
| 175 | + | msg := strings.ReplaceAll(c.Message, "\r\n", "\n") | |
| 177 | 176 | _, body, _ := strings.Cut(msg, "\n") | |
| 178 | 177 | return messageText(strings.TrimSpace(body)) | |
| 179 | 178 | } | |
| 180 | 179 | ||
| 181 | 180 | // diffNote says what is missing from an incomplete diff, "" for a complete one. | |
| 182 | - | func diffNote(d *pb.Diff, shown, total int) string { | |
| 183 | - | if !d.GetTruncated() { | |
| 181 | + | func diffNote(d domain.Diff, shown, total int) string { | |
| 182 | + | if !d.Truncated { | |
| 184 | 183 | return "" | |
| 185 | 184 | } | |
| 186 | 185 | var parts []string | |
| @@ -188,8 +187,8 @@ func diffNote(d *pb.Diff, shown, total int) string { | |||
| 188 | 187 | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | |
| 189 | 188 | } | |
| 190 | 189 | cut := 0 | |
| 191 | - | for _, f := range d.GetFiles() { | |
| 192 | - | if f.GetTruncated() { | |
| 190 | + | for _, f := range d.Files { | |
| 191 | + | if f.Truncated { | |
| 193 | 192 | cut++ | |
| 194 | 193 | } | |
| 195 | 194 | } | |
M internal/adapters/web/commits.go +13 -16
| @@ -8,10 +8,9 @@ import ( | |||
| 8 | 8 | "net/url" | |
| 9 | 9 | "path" | |
| 10 | 10 | ||
| 11 | - | "connectrpc.com/connect" | |
| 12 | 11 | "github.com/a-h/templ" | |
| 13 | 12 | ||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // commitsPerPage is how many commits one page of history lists. | |
| @@ -44,7 +43,7 @@ type CommitsPage struct { | |||
| 44 | 43 | } | |
| 45 | 44 | ||
| 46 | 45 | // commits lists the history of a ref, newest first, a page at a time, with | |
| 47 | - | // an optional path to limit it to. The page token of the API travels in the | |
| 46 | + | // an optional path to limit it to. The page token of the use case travels in the | |
| 48 | 47 | // "page" query parameter. | |
| 49 | 48 | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 50 | 49 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| @@ -56,13 +55,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 56 | 55 | return nil, 0, errNotFound | |
| 57 | 56 | } | |
| 58 | 57 | token := r.URL.Query().Get("page") | |
| 59 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 60 | - | Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token, | |
| 61 | - | })) | |
| 58 | + | res, err := s.commitList(ctx, d.repo.Name, sel.rev, sel.path, commitsPerPage, token) | |
| 62 | 59 | if err != nil { | |
| 63 | 60 | return nil, 0, err | |
| 64 | 61 | } | |
| 65 | - | cs := res.Msg.GetCommits() | |
| 62 | + | cs := res.Commits | |
| 66 | 63 | if len(cs) == 0 && token == "" && sel.path != "" { | |
| 67 | 64 | // Git answers an empty history for a path that never existed. | |
| 68 | 65 | return nil, 0, errNotFound | |
| @@ -70,7 +67,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 70 | 67 | ||
| 71 | 68 | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | |
| 72 | 69 | base := repoHref(d.name(), "commits", sel.name, sel.path) | |
| 73 | - | if t := res.Msg.GetNextPageToken(); t != "" { | |
| 70 | + | if t := res.NextPageToken; t != "" { | |
| 74 | 71 | p.Older = base + "?page=" + url.QueryEscape(t) | |
| 75 | 72 | } | |
| 76 | 73 | if token != "" { | |
| @@ -99,16 +96,16 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, | |||
| 99 | 96 | } | |
| 100 | 97 | ||
| 101 | 98 | // fillDays groups the commits of a page by day and lists the refs among them. | |
| 102 | - | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | |
| 99 | + | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) { | |
| 103 | 100 | now := s.cfg.Now() | |
| 104 | 101 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 105 | 102 | for i, c := range cs { | |
| 106 | 103 | row := commitRow(d.name(), c, now) | |
| 107 | 104 | row.ID, row.Cursor = "c-"+row.Short, i == 0 | |
| 108 | - | row.Branches, row.Tags = branchesAt[c.GetId()], tagsAt[c.GetId()] | |
| 105 | + | row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()] | |
| 109 | 106 | day := "unknown date" | |
| 110 | - | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 111 | - | day = t.AsTime().UTC().Format("2006-01-02") | |
| 107 | + | if t := c.Committer.Time; !t.IsZero() { | |
| 108 | + | day = t.UTC().Format("2006-01-02") | |
| 112 | 109 | } | |
| 113 | 110 | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | |
| 114 | 111 | p.Days = append(p.Days, DayGroup{Day: day}) | |
| @@ -132,15 +129,15 @@ func (s *Server) pathLink(ctx context.Context, d *repoData, sel target) *Link { | |||
| 132 | 129 | if dir == "." { | |
| 133 | 130 | dir = "" | |
| 134 | 131 | } | |
| 135 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir})) | |
| 132 | + | tree, err := s.getTree(ctx, d.repo.Name, sel.rev, dir, false) | |
| 136 | 133 | if err != nil { | |
| 137 | 134 | return nil | |
| 138 | 135 | } | |
| 139 | - | for _, e := range tree.Msg.GetEntries() { | |
| 140 | - | if e.GetPath() != sel.path { | |
| 136 | + | for _, e := range tree.Entries { | |
| 137 | + | if e.Path != sel.path { | |
| 141 | 138 | continue | |
| 142 | 139 | } | |
| 143 | - | if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY { | |
| 140 | + | if e.Kind == domain.EntryDirectory { | |
| 144 | 141 | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | |
| 145 | 142 | } | |
| 146 | 143 | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | |
M internal/adapters/web/commits.templ +4 -5
| @@ -11,19 +11,18 @@ templ Commits(l Layout, p CommitsPage) { | |||
| 11 | 11 | <input | |
| 12 | 12 | id="commit-filter" | |
| 13 | 13 | type="search" | |
| 14 | - | data-filter | |
| 14 | + | data-filter=".days tr" | |
| 15 | + | data-groups=".days section" | |
| 15 | 16 | placeholder="Filter by subject, sha or author…" | |
| 16 | 17 | autocomplete="off" | |
| 17 | - | hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 18 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 19 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')" | |
| 18 | + | hx-on:input="ermite.filter(this)" | |
| 20 | 19 | /> | |
| 21 | 20 | </div> | |
| 22 | 21 | if p.Path != "" { | |
| 23 | 22 | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | |
| 24 | 23 | } | |
| 25 | 24 | if p.Live { | |
| 26 | - | <div id="commits-head"></div> | |
| 25 | + | @LiveSlot("commits-head") | |
| 27 | 26 | } | |
| 28 | 27 | <div class="days"> | |
| 29 | 28 | for _, g := range p.Days { | |
M internal/adapters/web/commits_templ.go +47 -47 generated file: collapsed
M internal/adapters/web/commits_test.go +21 -12
| @@ -4,17 +4,19 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | - | "net/http" | |
| 8 | 7 | "testing" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | func TestIsCommitID(t *testing.T) { | |
| 19 | + | t.Parallel() | |
| 18 | 20 | for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} { | |
| 19 | 21 | assert.True(t, isCommitID(s), s) | |
| 20 | 22 | } | |
| @@ -26,15 +28,22 @@ func TestIsCommitID(t *testing.T) { | |||
| 26 | 28 | // The ref switcher marks the ref the page shows, whatever the page: a tag, a | |
| 27 | 29 | // branch other than the default one, or none for a commit. | |
| 28 | 30 | func TestPinRefMarksCurrent(t *testing.T) { | |
| 29 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 31 | + | t.Parallel() | |
| 32 | + | b, tag := domain.RefKindBranch, domain.RefKindTag | |
| 33 | + | name, err := domain.ParseRepoName("demo") | |
| 34 | + | require.NoError(t, err) | |
| 30 | 35 | d := &repoData{ | |
| 31 | - | repo: &pb.Repo{Name: "demo", DefaultBranch: "main"}, | |
| 32 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "side")}, | |
| 33 | - | tags: []*pb.Ref{ref(tag, "v1")}, | |
| 36 | + | repo: ports.RepoView{Repo: domain.Repo{Name: name}, DefaultBranch: "main"}, | |
| 37 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "side")}, | |
| 38 | + | tags: []domain.Ref{ref(tag, "v1")}, | |
| 34 | 39 | } | |
| 35 | - | d.head = d.branches[0] | |
| 40 | + | d.head = &d.branches[0] | |
| 41 | + | // The list of the other repositories is not reachable: the layout does | |
| 42 | + | // without it. | |
| 43 | + | repos := mocks.NewRepoService(t) | |
| 44 | + | repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, errors.New("no list")) | |
| 36 | 45 | s, err := New(Config{ | |
| 37 | - | Repos: failing{err: errors.New("no list")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 46 | + | Repos: repos, Browse: mocks.NewBrowseService(t), | |
| 38 | 47 | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 39 | 48 | }) | |
| 40 | 49 | require.NoError(t, err) | |
| @@ -51,9 +60,9 @@ func TestPinRefMarksCurrent(t *testing.T) { | |||
| 51 | 60 | heads, tags []string | |
| 52 | 61 | ref, short string | |
| 53 | 62 | }{ | |
| 54 | - | {"", []string{"main"}, nil, "main", "c-main"}, | |
| 55 | - | {"v1", nil, []string{"v1"}, "v1", "c-v1"}, | |
| 56 | - | {"side", []string{"side"}, nil, "side", "c-side"}, | |
| 63 | + | {"", []string{"main"}, nil, "main", short(commitOf("main").String())}, | |
| 64 | + | {"v1", nil, []string{"v1"}, "v1", short(commitOf("v1").String())}, | |
| 65 | + | {"side", []string{"side"}, nil, "side", short(commitOf("side").String())}, | |
| 57 | 66 | {"abcdef0123", nil, nil, "detached", "abcdef0"}, | |
| 58 | 67 | } { | |
| 59 | 68 | tg, ok := d.locate(tt.rest) | |
M internal/adapters/web/compare.go +43 -43
| @@ -7,10 +7,10 @@ import ( | |||
| 7 | 7 | "net/http" | |
| 8 | 8 | "slices" | |
| 9 | 9 | ||
| 10 | - | "connectrpc.com/connect" | |
| 11 | 10 | "github.com/a-h/templ" | |
| 12 | 11 | ||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // compareDepth bounds the commits read on each side to find where a branch | |
| @@ -27,6 +27,8 @@ const compareDepth = maxComparePages * pageSize | |||
| 27 | 27 | // own diff is then the whole change). Otherwise it lists the commits, which | |
| 28 | 28 | // link to their own diffs, and says the whole diff comes with the merge | |
| 29 | 29 | // 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 | |
| 30 | 32 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 31 | 33 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 32 | 34 | if err != nil { | |
| @@ -36,25 +38,25 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 36 | 38 | if !ok || rest != "" || d.head == nil { | |
| 37 | 39 | return nil, 0, errNotFound | |
| 38 | 40 | } | |
| 39 | - | tgt := d.head | |
| 41 | + | tgt := *d.head | |
| 40 | 42 | name := d.name() | |
| 41 | 43 | ||
| 42 | 44 | p := ComparePage{ | |
| 43 | - | Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()), | |
| 44 | - | SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()), | |
| 45 | + | Source: visibleText(src.ShortName), Target: visibleText(tgt.ShortName), | |
| 46 | + | SourceHref: repoHref(name, "tree", src.ShortName), TargetHref: repoHref(name, "tree", tgt.ShortName), | |
| 45 | 47 | CLI: Clone{ID: "cli-create", Lines: []string{ | |
| 46 | - | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.GetShortName()) + " --title <title>", | |
| 48 | + | "ermite mr create " + shellWord(name) + " --source " + shellWord(src.ShortName) + " --title <title>", | |
| 47 | 49 | }}, | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | - | same := src.GetShortName() == tgt.GetShortName() | |
| 51 | - | var ahead []*pb.Commit | |
| 52 | + | same := src.ShortName == tgt.ShortName | |
| 53 | + | var ahead []domain.Commit | |
| 52 | 54 | exact, behind := false, false | |
| 53 | 55 | if same { | |
| 54 | 56 | p.Note = "this is the default branch: there is nothing to compare it with." | |
| 55 | 57 | p.AheadText = "0 commits" | |
| 56 | 58 | } else { | |
| 57 | - | ahead, exact, behind, err = s.commitsAhead(ctx, name, src.GetCommitId(), tgt.GetCommitId()) | |
| 59 | + | ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String()) | |
| 58 | 60 | if err != nil { | |
| 59 | 61 | return nil, 0, err | |
| 60 | 62 | } | |
| @@ -79,19 +81,19 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 79 | 81 | ||
| 80 | 82 | // The exact diff: one commit on top of the tip of the target. | |
| 81 | 83 | if exact && !behind && len(ahead) == 1 { | |
| 82 | - | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: name, Revision: ahead[0].GetId()})) | |
| 84 | + | res, err := s.getCommit(ctx, d.repo.Name, ahead[0].ID.String()) | |
| 83 | 85 | if err != nil { | |
| 84 | 86 | return nil, 0, err | |
| 85 | 87 | } | |
| 86 | 88 | p.HasDiff = true | |
| 87 | - | p.Files = diffFiles(res.Msg.GetDiff().GetFiles()) | |
| 88 | - | p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions()) | |
| 89 | - | if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" { | |
| 89 | + | p.Files = diffFiles(res.Diff.Files) | |
| 90 | + | p.NFiles, p.Adds, p.Dels = res.Stats.FilesChanged, res.Stats.Additions, res.Stats.Deletions | |
| 91 | + | if n := diffNote(res.Diff, len(p.Files), p.NFiles); n != "" { | |
| 90 | 92 | p.Note = n | |
| 91 | 93 | } | |
| 92 | 94 | lines, invisible := diffLines(p.Files) | |
| 93 | 95 | p.Invisible = invisible | |
| 94 | - | self := repoHref(name, "compare", src.GetShortName()) | |
| 96 | + | self := repoHref(name, "compare", src.ShortName) | |
| 95 | 97 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | |
| 96 | 98 | } else if len(ahead) > 1 && exact { | |
| 97 | 99 | if p.Note == "" { | |
| @@ -100,32 +102,32 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 100 | 102 | } | |
| 101 | 103 | ||
| 102 | 104 | // An open request for the same pair, or the form to open one. | |
| 103 | - | var existing *pb.MergeRequest | |
| 105 | + | var existing *domain.MergeRequest | |
| 104 | 106 | if !same { | |
| 105 | - | existing, err = s.openRequestFor(ctx, name, src.GetShortName(), tgt.GetShortName()) | |
| 107 | + | existing, err = s.openRequestFor(ctx, d.repo.Name, src.ShortName, tgt.ShortName) | |
| 106 | 108 | if err != nil { | |
| 107 | 109 | return nil, 0, err | |
| 108 | 110 | } | |
| 109 | 111 | } | |
| 110 | 112 | switch { | |
| 111 | 113 | case existing != nil: | |
| 112 | - | p.Existing = &Link{Label: "!" + itoa(int(existing.GetNumber())) + " " + visibleText(existing.GetTitle()), Href: mrHref(name, existing.GetNumber())} | |
| 114 | + | p.Existing = &Link{Label: "!" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)} | |
| 113 | 115 | case SignedIn(ctx) && !same && len(ahead) > 0: | |
| 114 | 116 | title := "" | |
| 115 | 117 | if len(ahead) == 1 { | |
| 116 | - | title = ahead[0].GetSummary() | |
| 118 | + | title = ahead[0].Summary | |
| 117 | 119 | } else { | |
| 118 | - | title = ahead[len(ahead)-1].GetSummary() | |
| 120 | + | title = ahead[len(ahead)-1].Summary | |
| 119 | 121 | } | |
| 120 | 122 | p.Create = &CreateForm{ | |
| 121 | - | Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(), | |
| 123 | + | Action: mrBase(name), Source: src.ShortName, Target: tgt.ShortName, | |
| 122 | 124 | Title: cutRunes(title, 120), | |
| 123 | 125 | } | |
| 124 | 126 | } | |
| 125 | 127 | ||
| 126 | 128 | l := s.repoLayout(ctx, d, "merge-requests") | |
| 127 | 129 | l.Title = "compare " + p.Source + " · " + name + " · " + s.instance | |
| 128 | - | pinRef(&l, target{rev: src.GetCommitId(), name: src.GetShortName()}) | |
| 130 | + | pinRef(&l, target{rev: src.CommitID.String(), name: src.ShortName}) | |
| 129 | 131 | l.Crumbs[0].Current = false | |
| 130 | 132 | l.Crumbs = append(l.Crumbs, | |
| 131 | 133 | Crumb{Label: "merge requests", Href: mrBase(name)}, | |
| @@ -140,67 +142,65 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 140 | 142 | // first. exact is false when the walk gave up before finding where the source | |
| 141 | 143 | // meets the target. behind is true when the target has commits the source | |
| 142 | 144 | // lacks (the tip of the target is not an ancestor of the source). | |
| 143 | - | func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) { | |
| 145 | + | // | |
| 146 | + | //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 | |
| 147 | + | func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) { | |
| 144 | 148 | if srcTip == tgtTip { | |
| 145 | 149 | return nil, true, false, nil | |
| 146 | 150 | } | |
| 147 | 151 | onTarget := map[string]bool{} | |
| 148 | 152 | tokenT := "" | |
| 149 | 153 | for len(onTarget) < compareDepth { | |
| 150 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 151 | - | Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT, | |
| 152 | - | })) | |
| 154 | + | res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT) | |
| 153 | 155 | if err != nil { | |
| 154 | 156 | return nil, false, false, err | |
| 155 | 157 | } | |
| 156 | - | for _, c := range res.Msg.GetCommits() { | |
| 157 | - | onTarget[c.GetId()] = true | |
| 158 | + | for _, c := range res.Commits { | |
| 159 | + | onTarget[c.ID.String()] = true | |
| 158 | 160 | } | |
| 159 | - | if tokenT = res.Msg.GetNextPageToken(); tokenT == "" { | |
| 161 | + | if tokenT = res.NextPageToken; tokenT == "" { | |
| 160 | 162 | break | |
| 161 | 163 | } | |
| 162 | 164 | } | |
| 163 | 165 | token := "" | |
| 164 | 166 | for n := 0; n < compareDepth; { | |
| 165 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 166 | - | Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token, | |
| 167 | - | })) | |
| 167 | + | res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token) | |
| 168 | 168 | if err != nil { | |
| 169 | 169 | return nil, false, false, err | |
| 170 | 170 | } | |
| 171 | - | for _, c := range res.Msg.GetCommits() { | |
| 171 | + | for _, c := range res.Commits { | |
| 172 | 172 | n++ | |
| 173 | - | if onTarget[c.GetId()] { | |
| 173 | + | if onTarget[c.ID.String()] { | |
| 174 | 174 | // The first commit of the source that the target has: where the | |
| 175 | 175 | // branch left it. The target moved on when this is not its tip. | |
| 176 | - | return ahead, true, c.GetId() != tgtTip, nil | |
| 176 | + | return ahead, true, c.ID.String() != tgtTip, nil | |
| 177 | 177 | } | |
| 178 | 178 | ahead = append(ahead, c) | |
| 179 | 179 | } | |
| 180 | - | if token = res.Msg.GetNextPageToken(); token == "" { | |
| 180 | + | if token = res.NextPageToken; token == "" { | |
| 181 | 181 | // The whole history of the source is ahead: no common history. | |
| 182 | 182 | return ahead, true, true, nil | |
| 183 | 183 | } | |
| 184 | 184 | } | |
| 185 | - | return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil | |
| 185 | + | return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 188 | // openRequestFor finds the open merge request with this source and target. | |
| 189 | - | func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) { | |
| 189 | + | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { | |
| 190 | 190 | token := "" | |
| 191 | 191 | for range maxOpenPages { | |
| 192 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | |
| 193 | - | Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token, | |
| 194 | - | })) | |
| 192 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), repo, ports.ListMergeRequestsParams{ | |
| 193 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: token, | |
| 194 | + | }) | |
| 195 | 195 | if err != nil { | |
| 196 | 196 | return nil, err | |
| 197 | 197 | } | |
| 198 | - | for _, m := range res.Msg.GetMergeRequests() { | |
| 199 | - | if m.GetSourceBranch() == source && m.GetTargetBranch() == target { | |
| 198 | + | for i := range res.MergeRequests { | |
| 199 | + | if m := &res.MergeRequests[i]; m.Source == source && m.Target == target { | |
| 200 | 200 | return m, nil | |
| 201 | 201 | } | |
| 202 | 202 | } | |
| 203 | - | if token = res.Msg.GetNextPageToken(); token == "" { | |
| 203 | + | if token = res.NextPageToken; token == "" { | |
| 204 | 204 | break | |
| 205 | 205 | } | |
| 206 | 206 | } | |
M internal/adapters/web/components.templ +3 -2
| @@ -22,9 +22,10 @@ templ CloneBlock(c Clone) { | |||
| 22 | 22 | <button | |
| 23 | 23 | type="button" | |
| 24 | 24 | class="btn copy" | |
| 25 | + | data-js | |
| 25 | 26 | data-label="Copy" | |
| 26 | 27 | aria-label={ "copy: " + line } | |
| 27 | - | hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label" | |
| 28 | + | hx-on:click="ermite.copy(this)" | |
| 28 | 29 | >Copy</button> | |
| 29 | 30 | </div> | |
| 30 | 31 | } | |
| @@ -76,7 +77,7 @@ templ ReadmeCard(r Readme) { | |||
| 76 | 77 | // ReadmeView is a rendered README. Its HTML comes from the Markdown | |
| 77 | 78 | // renderer, which drops raw HTML and filters URLs. | |
| 78 | 79 | templ ReadmeView(r Readme) { | |
| 79 | - | <article class="prose" aria-label={ r.Name }> | |
| 80 | + | <article class="prose" aria-label={ r.Name } hx-ignore> | |
| 80 | 81 | if r.Note != "" { | |
| 81 | 82 | <p class="dim">{ r.Note }</p> | |
| 82 | 83 | } else { | |
M internal/adapters/web/components_templ.go +26 -26 generated file: collapsed
A internal/adapters/web/compress.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "crypto/sha256" | |
| 9 | + | "encoding/hex" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | "sync" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // minCompress is the size under which a page is sent as it is: the gzip | |
| 18 | + | // header and the CPU cost are not worth it for a few hundred bytes. | |
| 19 | + | const minCompress = 1024 | |
| 20 | + | ||
| 21 | + | // gzipLevel is the level of the pages, compressed on each request: a good | |
| 22 | + | // ratio at a cost small enough for a render that already takes longer. The | |
| 23 | + | // assets, compressed once at start, use the best level. | |
| 24 | + | const gzipLevel = 5 | |
| 25 | + | ||
| 26 | + | // gzipWriters recycles the writers of the pages: a gzip.Writer holds about | |
| 27 | + | // 800 KiB of state. | |
| 28 | + | var gzipWriters = sync.Pool{New: func() any { | |
| 29 | + | zw, _ := gzip.NewWriterLevel(nil, gzipLevel) // the level is valid | |
| 30 | + | return zw | |
| 31 | + | }} | |
| 32 | + | ||
| 33 | + | // gzipPage compresses b with a pooled writer. | |
| 34 | + | func gzipPage(b []byte) ([]byte, error) { | |
| 35 | + | var out bytes.Buffer | |
| 36 | + | zw := gzipWriters.Get().(*gzip.Writer) | |
| 37 | + | defer func() { | |
| 38 | + | // Drop the reference to out, so a pooled writer does not keep the | |
| 39 | + | // last page alive. | |
| 40 | + | zw.Reset(io.Discard) | |
| 41 | + | gzipWriters.Put(zw) | |
| 42 | + | }() | |
| 43 | + | zw.Reset(&out) | |
| 44 | + | if _, err := zw.Write(b); err != nil { | |
| 45 | + | return nil, err | |
| 46 | + | } | |
| 47 | + | if err := zw.Close(); err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | return out.Bytes(), nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // gzipBest compresses b once, at the best level (for the assets). | |
| 54 | + | func gzipBest(b []byte) ([]byte, error) { | |
| 55 | + | var out bytes.Buffer | |
| 56 | + | zw, err := gzip.NewWriterLevel(&out, gzip.BestCompression) | |
| 57 | + | if err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | if _, err := zw.Write(b); err != nil { | |
| 61 | + | return nil, err | |
| 62 | + | } | |
| 63 | + | if err := zw.Close(); err != nil { | |
| 64 | + | return nil, err | |
| 65 | + | } | |
| 66 | + | return out.Bytes(), nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // acceptsGzip reports whether the Accept-Encoding of a request allows gzip | |
| 70 | + | // (RFC 9110 §12.5.3): gzip, or its alias x-gzip, with a quality above 0, or | |
| 71 | + | // "*" when gzip is not named. A quality of 0 refuses the coding; a malformed | |
| 72 | + | // quality is a refusal as well. | |
| 73 | + | func acceptsGzip(h http.Header) bool { | |
| 74 | + | star, starSet := false, false | |
| 75 | + | for _, line := range h.Values("Accept-Encoding") { | |
| 76 | + | for part := range strings.SplitSeq(line, ",") { | |
| 77 | + | coding, params, _ := strings.Cut(part, ";") | |
| 78 | + | coding = strings.ToLower(strings.TrimSpace(coding)) | |
| 79 | + | if coding != "gzip" && coding != "x-gzip" && coding != "*" { | |
| 80 | + | continue | |
| 81 | + | } | |
| 82 | + | ok := qualityPositive(params) | |
| 83 | + | switch coding { | |
| 84 | + | case "*": | |
| 85 | + | star, starSet = ok, true | |
| 86 | + | default: | |
| 87 | + | return ok | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return starSet && star | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // qualityPositive reads the q parameter of a coding (1 when absent) and | |
| 95 | + | // reports whether it is above 0. | |
| 96 | + | func qualityPositive(params string) bool { | |
| 97 | + | for p := range strings.SplitSeq(params, ";") { | |
| 98 | + | k, v, found := strings.Cut(p, "=") | |
| 99 | + | if !found || !strings.EqualFold(strings.TrimSpace(k), "q") { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | q, err := strconv.ParseFloat(strings.TrimSpace(v), 64) | |
| 103 | + | return err == nil && q > 0 | |
| 104 | + | } | |
| 105 | + | return true | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // addVary adds a field to Vary unless it is already there (or Vary is *). | |
| 109 | + | func addVary(h http.Header, field string) { | |
| 110 | + | for _, v := range h.Values("Vary") { | |
| 111 | + | for f := range strings.SplitSeq(v, ",") { | |
| 112 | + | if t := strings.TrimSpace(f); t == "*" || strings.EqualFold(t, field) { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | h.Add("Vary", field) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // noTransform tells whether Cache-Control forbids changing the body. | |
| 121 | + | func noTransform(h http.Header) bool { | |
| 122 | + | return hasCacheDirective(h, "no-transform") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // hasCacheDirective tells whether Cache-Control holds the directive. | |
| 126 | + | func hasCacheDirective(h http.Header, name string) bool { | |
| 127 | + | for _, v := range h.Values("Cache-Control") { | |
| 128 | + | for d := range strings.SplitSeq(v, ",") { | |
| 129 | + | if strings.EqualFold(strings.TrimSpace(d), name) { | |
| 130 | + | return true | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // pageETag is the strong validator of a rendered page: a hash of its | |
| 138 | + | // identity bytes and of the session state, so that the page of the owner | |
| 139 | + | // never shares a tag with the page of a visitor. The gzip representation | |
| 140 | + | // has a tag of its own (RFC 9110 §8.8.3). | |
| 141 | + | func pageETag(body []byte, signedIn, gzipped bool) string { | |
| 142 | + | h := sha256.New() | |
| 143 | + | if signedIn { | |
| 144 | + | h.Write([]byte{1}) | |
| 145 | + | } else { | |
| 146 | + | h.Write([]byte{0}) | |
| 147 | + | } | |
| 148 | + | h.Write(body) | |
| 149 | + | tag := hex.EncodeToString(h.Sum(nil)[:16]) | |
| 150 | + | if gzipped { | |
| 151 | + | tag += "-gz" | |
| 152 | + | } | |
| 153 | + | return `"` + tag + `"` | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // etagMatches implements the weak comparison of If-None-Match (RFC 9110 | |
| 157 | + | // §13.1.2): a list of entity tags, each optionally weak, or "*". | |
| 158 | + | func etagMatches(header, etag string) bool { | |
| 159 | + | want := strings.TrimPrefix(etag, "W/") | |
| 160 | + | s := header | |
| 161 | + | for { | |
| 162 | + | s = strings.TrimLeft(s, " \t,") | |
| 163 | + | if s == "" { | |
| 164 | + | return false | |
| 165 | + | } | |
| 166 | + | if s[0] == '*' { | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | s = strings.TrimPrefix(s, "W/") | |
| 170 | + | if s == "" || s[0] != '"' { | |
| 171 | + | return false // malformed: nothing after this can be trusted | |
| 172 | + | } | |
| 173 | + | end := strings.IndexByte(s[1:], '"') | |
| 174 | + | if end < 0 { | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | if s[:end+2] == want { | |
| 178 | + | return true | |
| 179 | + | } | |
| 180 | + | s = s[end+2:] | |
| 181 | + | } | |
| 182 | + | } | |
A internal/adapters/web/compress_test.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "compress/gzip" | |
| 8 | + | "context" | |
| 9 | + | "errors" | |
| 10 | + | "io" | |
| 11 | + | "net/http" | |
| 12 | + | "net/http/httptest" | |
| 13 | + | "strings" | |
| 14 | + | "testing" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/a-h/templ" | |
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | func TestAcceptsGzip(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | for _, tc := range []struct { | |
| 27 | + | name string | |
| 28 | + | header []string | |
| 29 | + | want bool | |
| 30 | + | }{ | |
| 31 | + | {"absent", nil, false}, | |
| 32 | + | {"gzip", []string{"gzip"}, true}, | |
| 33 | + | {"list", []string{"deflate, gzip, br"}, true}, | |
| 34 | + | {"case", []string{"GZip"}, true}, | |
| 35 | + | {"x-gzip alias", []string{"x-gzip"}, true}, | |
| 36 | + | {"q=1", []string{"gzip;q=1.0"}, true}, | |
| 37 | + | {"q spaces", []string{"gzip ; q=0.5"}, true}, | |
| 38 | + | {"q=0 refuses", []string{"gzip;q=0"}, false}, | |
| 39 | + | {"q=0.0 refuses", []string{"br, gzip;q=0.0"}, false}, | |
| 40 | + | {"bad q refuses", []string{"gzip;q=abc"}, false}, | |
| 41 | + | {"identity only", []string{"identity"}, false}, | |
| 42 | + | {"br only", []string{"br"}, false}, | |
| 43 | + | {"star", []string{"*"}, true}, | |
| 44 | + | {"star q=0", []string{"*;q=0"}, false}, | |
| 45 | + | {"gzip refused beats star", []string{"gzip;q=0, *"}, false}, | |
| 46 | + | {"gzip accepted beats star q=0", []string{"*;q=0, gzip"}, true}, | |
| 47 | + | {"several lines", []string{"br", "gzip"}, true}, | |
| 48 | + | {"substring is not a coding", []string{"gzipped"}, false}, | |
| 49 | + | } { | |
| 50 | + | t.Run(tc.name, func(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 52 | + | h := http.Header{} | |
| 53 | + | for _, v := range tc.header { | |
| 54 | + | h.Add("Accept-Encoding", v) | |
| 55 | + | } | |
| 56 | + | assert.Equal(t, tc.want, acceptsGzip(h)) | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestEtagMatches(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | const tag = `"abc"` | |
| 64 | + | for _, tc := range []struct { | |
| 65 | + | name, header, etag string | |
| 66 | + | want bool | |
| 67 | + | }{ | |
| 68 | + | {"empty", "", tag, false}, | |
| 69 | + | {"same", `"abc"`, tag, true}, | |
| 70 | + | {"other", `"abd"`, tag, false}, | |
| 71 | + | {"weak client tag", `W/"abc"`, tag, true}, | |
| 72 | + | {"list", `"x", "abc", "y"`, tag, true}, | |
| 73 | + | {"list no space", `"x","abc"`, tag, true}, | |
| 74 | + | {"list without it", `"x", W/"y"`, tag, false}, | |
| 75 | + | {"star", `*`, tag, true}, | |
| 76 | + | {"unquoted", `abc`, tag, false}, | |
| 77 | + | {"unterminated", `"abc`, tag, false}, | |
| 78 | + | {"prefix is not equal", `"ab"`, tag, false}, | |
| 79 | + | {"weak server tag", `"abc"`, `W/"abc"`, true}, | |
| 80 | + | {"comma in a tag", `"a,b", "abc"`, tag, true}, | |
| 81 | + | } { | |
| 82 | + | t.Run(tc.name, func(t *testing.T) { | |
| 83 | + | t.Parallel() | |
| 84 | + | assert.Equal(t, tc.want, etagMatches(tc.header, tc.etag)) | |
| 85 | + | }) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestPageETag(t *testing.T) { | |
| 90 | + | t.Parallel() | |
| 91 | + | body := []byte("<html>x</html>") | |
| 92 | + | a := pageETag(body, false, false) | |
| 93 | + | assert.Equal(t, a, pageETag(body, false, false), "stable") | |
| 94 | + | assert.NotEqual(t, a, pageETag(body, true, false), "owner and visitor do not share a tag") | |
| 95 | + | assert.NotEqual(t, a, pageETag(body, false, true), "gzip is another representation") | |
| 96 | + | assert.NotEqual(t, a, pageETag([]byte("<html>y</html>"), false, false)) | |
| 97 | + | assert.True(t, strings.HasPrefix(a, `"`) && strings.HasSuffix(a, `"`)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func newBareServer(t *testing.T) *Server { | |
| 101 | + | t.Helper() | |
| 102 | + | return newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func gunzip(t *testing.T, b []byte) string { | |
| 106 | + | t.Helper() | |
| 107 | + | zr, err := gzip.NewReader(bytes.NewReader(b)) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | out, err := io.ReadAll(zr) | |
| 110 | + | require.NoError(t, err) | |
| 111 | + | return string(out) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // write: gzip by size and Accept-Encoding, validators, 304. | |
| 115 | + | func TestWritePage(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 117 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |
| 118 | + | small := "<p>tiny</p>" | |
| 119 | + | ||
| 120 | + | for _, tc := range []struct { | |
| 121 | + | name string | |
| 122 | + | method string | |
| 123 | + | ae string | |
| 124 | + | body string | |
| 125 | + | status int | |
| 126 | + | cc string // Cache-Control set by the handler beforehand | |
| 127 | + | wantGzip bool | |
| 128 | + | wantTag bool | |
| 129 | + | }{ | |
| 130 | + | {"big page, gzip", "GET", "gzip", big, 200, "", true, true}, | |
| 131 | + | {"big page, no Accept-Encoding", "GET", "", big, 200, "", false, true}, | |
| 132 | + | {"big page, gzip refused", "GET", "gzip;q=0", big, 200, "", false, true}, | |
| 133 | + | {"big page, br only", "GET", "br", big, 200, "", false, true}, | |
| 134 | + | {"small page not compressed", "GET", "gzip", small, 200, "", false, true}, | |
| 135 | + | {"HEAD keeps headers, no body", "HEAD", "gzip", big, 200, "", true, true}, | |
| 136 | + | {"404 page compressed, no tag", "GET", "gzip", big, 404, "", true, false}, | |
| 137 | + | {"no-store handler: no tag", "GET", "gzip", big, 200, "no-store", true, false}, | |
| 138 | + | {"no-transform: untouched", "GET", "gzip", big, 200, "no-transform", false, true}, | |
| 139 | + | } { | |
| 140 | + | t.Run(tc.name, func(t *testing.T) { | |
| 141 | + | t.Parallel() | |
| 142 | + | srv := newBareServer(t) | |
| 143 | + | rec := httptest.NewRecorder() | |
| 144 | + | r := httptest.NewRequestWithContext(context.Background(), tc.method, "/", nil) | |
| 145 | + | if tc.ae != "" { | |
| 146 | + | r.Header.Set("Accept-Encoding", tc.ae) | |
| 147 | + | } | |
| 148 | + | if tc.cc != "" { | |
| 149 | + | rec.Header().Set("Cache-Control", tc.cc) | |
| 150 | + | } | |
| 151 | + | srv.write(r.Context(), rec, r, tc.status, templ.Raw(tc.body)) | |
| 152 | + | ||
| 153 | + | res := rec.Result() | |
| 154 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |
| 155 | + | raw, _ := io.ReadAll(res.Body) | |
| 156 | + | assert.Equal(t, tc.status, res.StatusCode) | |
| 157 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |
| 158 | + | assert.Equal(t, tc.wantTag, res.Header.Get("ETag") != "") | |
| 159 | + | switch { | |
| 160 | + | case tc.method == "HEAD": | |
| 161 | + | assert.Empty(t, raw) | |
| 162 | + | assert.Equal(t, tc.wantGzip, res.Header.Get("Content-Encoding") == "gzip") | |
| 163 | + | case tc.wantGzip: | |
| 164 | + | assert.Equal(t, "gzip", res.Header.Get("Content-Encoding")) | |
| 165 | + | assert.Equal(t, itoa(len(raw)), res.Header.Get("Content-Length")) | |
| 166 | + | assert.Less(t, len(raw), len(tc.body)) | |
| 167 | + | assert.Equal(t, tc.body, gunzip(t, raw)) | |
| 168 | + | default: | |
| 169 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |
| 170 | + | assert.Equal(t, tc.body, string(raw)) | |
| 171 | + | } | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestWritePageConditional(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | big := "<p>" + strings.Repeat("ermite page ", 400) + "</p>" | |
| 179 | + | srv := newBareServer(t) | |
| 180 | + | do := func(ae, inm string) *http.Response { | |
| 181 | + | rec := httptest.NewRecorder() | |
| 182 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", "/", nil) | |
| 183 | + | if ae != "" { | |
| 184 | + | r.Header.Set("Accept-Encoding", ae) | |
| 185 | + | } | |
| 186 | + | if inm != "" { | |
| 187 | + | r.Header.Set("If-None-Match", inm) | |
| 188 | + | } | |
| 189 | + | srv.write(r.Context(), rec, r, 200, templ.Raw(big)) | |
| 190 | + | res := rec.Result() | |
| 191 | + | t.Cleanup(func() { _ = res.Body.Close() }) | |
| 192 | + | return res | |
| 193 | + | } | |
| 194 | + | plain, gz := do("", "").Header.Get("ETag"), do("gzip", "").Header.Get("ETag") | |
| 195 | + | require.NotEmpty(t, plain) | |
| 196 | + | require.NotEmpty(t, gz) | |
| 197 | + | assert.NotEqual(t, plain, gz, "identity and gzip are different representations") | |
| 198 | + | ||
| 199 | + | for _, tc := range []struct { | |
| 200 | + | name, ae, inm string | |
| 201 | + | want int | |
| 202 | + | }{ | |
| 203 | + | {"identity match", "", plain, 304}, | |
| 204 | + | {"gzip match", "gzip", gz, 304}, | |
| 205 | + | {"list", "gzip", `"nope", ` + gz, 304}, | |
| 206 | + | {"weak prefix", "", "W/" + plain, 304}, | |
| 207 | + | {"star", "", "*", 304}, | |
| 208 | + | {"identity tag for gzip request", "gzip", plain, 200}, | |
| 209 | + | {"gzip tag for identity request", "", gz, 200}, | |
| 210 | + | {"stale", "", `"stale"`, 200}, | |
| 211 | + | } { | |
| 212 | + | t.Run(tc.name, func(t *testing.T) { | |
| 213 | + | t.Parallel() | |
| 214 | + | res := do(tc.ae, tc.inm) | |
| 215 | + | assert.Equal(t, tc.want, res.StatusCode) | |
| 216 | + | b, _ := io.ReadAll(res.Body) | |
| 217 | + | if tc.want == 304 { | |
| 218 | + | assert.Empty(t, b) | |
| 219 | + | assert.NotEmpty(t, res.Header.Get("ETag")) | |
| 220 | + | assert.Equal(t, "no-cache", res.Header.Get("Cache-Control")) | |
| 221 | + | assert.Contains(t, res.Header.Values("Vary"), "Accept-Encoding") | |
| 222 | + | assert.Empty(t, res.Header.Get("Content-Encoding")) | |
| 223 | + | } else { | |
| 224 | + | assert.NotEmpty(t, b) | |
| 225 | + | } | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // The owner's page is private and never revalidated, whatever the client sends. | |
| 231 | + | func TestWritePageOwnerHasNoValidator(t *testing.T) { | |
| 232 | + | t.Parallel() | |
| 233 | + | srv := newBareServer(t) | |
| 234 | + | ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{}) | |
| 235 | + | rec := httptest.NewRecorder() | |
| 236 | + | r := httptest.NewRequestWithContext(ctx, "GET", "/", nil) | |
| 237 | + | r.Header.Set("If-None-Match", "*") | |
| 238 | + | srv.write(ctx, rec, r, 200, templ.Raw("<p>mine</p>")) | |
| 239 | + | assert.Equal(t, 200, rec.Code) | |
| 240 | + | assert.Equal(t, sessionCache, rec.Header().Get("Cache-Control")) | |
| 241 | + | assert.Empty(t, rec.Header().Get("ETag")) | |
| 242 | + | assert.Contains(t, rec.Header().Values("Vary"), "Cookie") | |
| 243 | + | assert.Contains(t, rec.Header().Values("Vary"), "Accept-Encoding") | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // The assets: gzip once at load, chosen by Accept-Encoding, not for fonts. | |
| 247 | + | func TestAssetsGzip(t *testing.T) { | |
| 248 | + | t.Parallel() | |
| 249 | + | srv := newBareServer(t) | |
| 250 | + | get := func(url, ae string) *httptest.ResponseRecorder { | |
| 251 | + | rec := httptest.NewRecorder() | |
| 252 | + | r := httptest.NewRequestWithContext(context.Background(), "GET", url, nil) | |
| 253 | + | if ae != "" { | |
| 254 | + | r.Header.Set("Accept-Encoding", ae) | |
| 255 | + | } | |
| 256 | + | srv.ServeHTTP(rec, r) | |
| 257 | + | return rec | |
| 258 | + | } | |
| 259 | + | u := srv.assets.urls | |
| 260 | + | for _, tc := range []struct { | |
| 261 | + | name, url, ae string | |
| 262 | + | wantGzip bool | |
| 263 | + | vary bool | |
| 264 | + | }{ | |
| 265 | + | {"css gzip", u.CSS, "gzip, br", true, true}, | |
| 266 | + | {"css identity", u.CSS, "", false, true}, | |
| 267 | + | {"css refused", u.CSS, "gzip;q=0", false, true}, | |
| 268 | + | {"js gzip", u.HTMX, "gzip", true, true}, | |
| 269 | + | {"rules gzip", u.Rules, "gzip", true, true}, | |
| 270 | + | {"svg gzip", u.Icon, "gzip", true, true}, | |
| 271 | + | {"font never", u.Font, "gzip", false, false}, | |
| 272 | + | } { | |
| 273 | + | t.Run(tc.name, func(t *testing.T) { | |
| 274 | + | t.Parallel() | |
| 275 | + | rec := get(tc.url, tc.ae) | |
| 276 | + | require.Equal(t, 200, rec.Code) | |
| 277 | + | assert.Equal(t, "public, max-age=31536000, immutable", rec.Header().Get("Cache-Control")) | |
| 278 | + | assert.Equal(t, tc.vary, strings.Contains(strings.Join(rec.Header().Values("Vary"), ","), "Accept-Encoding")) | |
| 279 | + | assert.Equal(t, itoa(rec.Body.Len()), rec.Header().Get("Content-Length")) | |
| 280 | + | plain := srv.assets.byName[strings.TrimPrefix(tc.url, StaticPrefix)].body | |
| 281 | + | if tc.wantGzip { | |
| 282 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |
| 283 | + | assert.Less(t, rec.Body.Len(), len(plain)) | |
| 284 | + | assert.Equal(t, string(plain), gunzip(t, rec.Body.Bytes())) | |
| 285 | + | } else { | |
| 286 | + | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |
| 287 | + | assert.Equal(t, string(plain), rec.Body.String()) | |
| 288 | + | } | |
| 289 | + | }) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | t.Run("HEAD has the length of the encoded body", func(t *testing.T) { | |
| 293 | + | rec := httptest.NewRecorder() | |
| 294 | + | r := httptest.NewRequestWithContext(context.Background(), "HEAD", u.CSS, nil) | |
| 295 | + | r.Header.Set("Accept-Encoding", "gzip") | |
| 296 | + | srv.ServeHTTP(rec, r) | |
| 297 | + | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |
| 298 | + | assert.Equal(t, itoa(len(srv.assets.byName[strings.TrimPrefix(u.CSS, StaticPrefix)].gz)), rec.Header().Get("Content-Length")) | |
| 299 | + | assert.Zero(t, rec.Body.Len()) | |
| 300 | + | }) | |
| 301 | + | ||
| 302 | + | t.Run("compressible types", func(t *testing.T) { | |
| 303 | + | for typ, want := range map[string]bool{ | |
| 304 | + | "text/css; charset=utf-8": true, "text/javascript; charset=utf-8": true, | |
| 305 | + | "application/speculationrules+json": true, "image/svg+xml": true, | |
| 306 | + | "font/woff2": false, "image/png": false, | |
| 307 | + | } { | |
| 308 | + | assert.Equal(t, want, compressible(typ), typ) | |
| 309 | + | } | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // The event stream is never compressed, even for a client that accepts gzip. | |
| 314 | + | func TestEventsNotCompressed(t *testing.T) { | |
| 315 | + | t.Parallel() | |
| 316 | + | f := &watchProbe{release: make(chan struct{})} | |
| 317 | + | ts := eventsServer(t, f, 20*time.Millisecond, 0) | |
| 318 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Accept-Encoding": "gzip"}) | |
| 319 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 320 | + | assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) | |
| 321 | + | assert.Empty(t, resp.Header.Get("Content-Encoding")) | |
| 322 | + | assert.Empty(t, resp.Header.Get("ETag")) | |
| 323 | + | assert.Contains(t, resp.Header.Get("Cache-Control"), "no-transform") | |
| 324 | + | } | |
M internal/adapters/web/diff.go +50 -50
| @@ -13,7 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/a-h/templ" | |
| 15 | 15 | ||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // Seg is a piece of a line of code: plain text, or the visible marker of an | |
| @@ -101,11 +101,11 @@ type DiffFile struct { | |||
| 101 | 101 | Hunks []DiffHunk | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | - | func lineNumber(n int32) string { | |
| 104 | + | func lineNumber(n int) string { | |
| 105 | 105 | if n <= 0 { | |
| 106 | 106 | return "" | |
| 107 | 107 | } | |
| 108 | - | return itoa(int(n)) | |
| 108 | + | return itoa(n) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | 111 | // segments splits a line of code into plain text and markers. One trailing | |
| @@ -197,79 +197,79 @@ func changeBar(add, del, most int) Bar { | |||
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | 199 | // statusLetter is the one-letter status of a file. | |
| 200 | - | func statusLetter(s pb.FileStatus) string { | |
| 200 | + | func statusLetter(s domain.FileStatus) string { | |
| 201 | 201 | switch s { | |
| 202 | - | case pb.FileStatus_FILE_STATUS_ADDED: | |
| 202 | + | case domain.FileAdded: | |
| 203 | 203 | return "A" | |
| 204 | - | case pb.FileStatus_FILE_STATUS_DELETED: | |
| 204 | + | case domain.FileDeleted: | |
| 205 | 205 | return "D" | |
| 206 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | |
| 206 | + | case domain.FileRenamed: | |
| 207 | 207 | return "R" | |
| 208 | - | case pb.FileStatus_FILE_STATUS_COPIED: | |
| 208 | + | case domain.FileCopied: | |
| 209 | 209 | return "C" | |
| 210 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | |
| 210 | + | case domain.FileTypeChanged: | |
| 211 | 211 | return "T" | |
| 212 | 212 | } | |
| 213 | 213 | return "M" | |
| 214 | 214 | } | |
| 215 | 215 | ||
| 216 | 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | |
| 217 | - | func hunkHeader(h *pb.Hunk) string { | |
| 218 | - | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) | |
| 219 | - | if t := h.GetHeader(); t != "" { | |
| 217 | + | func hunkHeader(h domain.Hunk) string { | |
| 218 | + | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) | |
| 219 | + | if t := h.Header; t != "" { | |
| 220 | 220 | s += " " + visibleText(strings.TrimSpace(t)) | |
| 221 | 221 | } | |
| 222 | 222 | return s | |
| 223 | 223 | } | |
| 224 | 224 | ||
| 225 | - | // diffLine converts a line of the API. | |
| 226 | - | func diffLine(l *pb.Line) DiffLine { | |
| 227 | - | dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())} | |
| 228 | - | switch l.GetKind() { | |
| 229 | - | case pb.LineKind_LINE_KIND_ADDITION: | |
| 230 | - | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber()) | |
| 231 | - | case pb.LineKind_LINE_KIND_DELETION: | |
| 232 | - | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber()) | |
| 225 | + | // diffLine converts a line of a hunk. | |
| 226 | + | func diffLine(l domain.Line) DiffLine { | |
| 227 | + | dl := DiffLine{Segs: segments(l.Text), NoEOL: l.NoNewlineAtEOF, oldN: l.OldNumber, newN: l.NewNumber} | |
| 228 | + | switch l.Kind { | |
| 229 | + | case domain.LineAddition: | |
| 230 | + | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.NewNumber) | |
| 231 | + | case domain.LineDeletion: | |
| 232 | + | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.OldNumber) | |
| 233 | 233 | default: | |
| 234 | 234 | dl.Kind, dl.Mark = "ctx", " " | |
| 235 | - | dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber()) | |
| 235 | + | dl.Old, dl.New = lineNumber(l.OldNumber), lineNumber(l.NewNumber) | |
| 236 | 236 | } | |
| 237 | 237 | return dl | |
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | // diffFiles converts the files of a diff for the page. | |
| 241 | - | func diffFiles(files []*pb.FileDiff) []DiffFile { | |
| 241 | + | func diffFiles(files []domain.FileDiff) []DiffFile { | |
| 242 | 242 | most := 0 | |
| 243 | 243 | for _, f := range files { | |
| 244 | - | most = max(most, int(f.GetAdditions()+f.GetDeletions())) | |
| 244 | + | most = max(most, f.Additions+f.Deletions) | |
| 245 | 245 | } | |
| 246 | 246 | out := make([]DiffFile, 0, len(files)) | |
| 247 | 247 | for i, f := range files { | |
| 248 | 248 | df := DiffFile{ | |
| 249 | - | ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()), | |
| 250 | - | Add: int(f.GetAdditions()), Del: int(f.GetDeletions()), | |
| 251 | - | Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most), | |
| 249 | + | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | |
| 250 | + | Add: f.Additions, Del: f.Deletions, | |
| 251 | + | Bar: changeBar(f.Additions, f.Deletions, most), | |
| 252 | 252 | // A file that is shown in full opens; a file with nothing to read | |
| 253 | 253 | // (binary, truncated) stays closed under its note. | |
| 254 | - | Open: len(f.GetHunks()) > 0 && !f.GetTruncated(), | |
| 254 | + | Open: len(f.Hunks) > 0 && !f.Truncated, | |
| 255 | 255 | } | |
| 256 | - | df.Key = f.GetNewPath() | |
| 257 | - | switch f.GetStatus() { | |
| 258 | - | case pb.FileStatus_FILE_STATUS_DELETED: | |
| 259 | - | df.Key = f.GetOldPath() | |
| 260 | - | df.Path = visibleText(f.GetOldPath()) | |
| 261 | - | case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED: | |
| 262 | - | df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath()) | |
| 256 | + | df.Key = f.NewPath | |
| 257 | + | switch f.Status { | |
| 258 | + | case domain.FileDeleted: | |
| 259 | + | df.Key = f.OldPath | |
| 260 | + | df.Path = visibleText(f.OldPath) | |
| 261 | + | case domain.FileRenamed, domain.FileCopied: | |
| 262 | + | df.Path = visibleText(f.OldPath) + " → " + visibleText(f.NewPath) | |
| 263 | 263 | default: | |
| 264 | - | df.Path = visibleText(f.GetNewPath()) | |
| 264 | + | df.Path = visibleText(f.NewPath) | |
| 265 | 265 | } | |
| 266 | 266 | df.Note = fileNote(f) | |
| 267 | - | if f.GetBinary() { | |
| 267 | + | if f.Binary { | |
| 268 | 268 | df.Bar = Bar{Rest: "bin"} | |
| 269 | 269 | } | |
| 270 | - | for _, h := range f.GetHunks() { | |
| 270 | + | for _, h := range f.Hunks { | |
| 271 | 271 | dh := DiffHunk{Header: hunkHeader(h)} | |
| 272 | - | for _, l := range h.GetLines() { | |
| 272 | + | for _, l := range h.Lines { | |
| 273 | 273 | dh.Lines = append(dh.Lines, diffLine(l)) | |
| 274 | 274 | } | |
| 275 | 275 | dh.Split = pairLines(dh.Lines) | |
| @@ -282,23 +282,23 @@ func diffFiles(files []*pb.FileDiff) []DiffFile { | |||
| 282 | 282 | ||
| 283 | 283 | // fileNote says what the hunks cannot: the kind of change when there is no | |
| 284 | 284 | // text to show, the mode change, the similarity of a rename. | |
| 285 | - | func fileNote(f *pb.FileDiff) string { | |
| 285 | + | func fileNote(f domain.FileDiff) string { | |
| 286 | 286 | var parts []string | |
| 287 | 287 | switch { | |
| 288 | - | case f.GetBinary(): | |
| 288 | + | case f.Binary: | |
| 289 | 289 | parts = append(parts, "binary file: no text diff") | |
| 290 | - | case f.GetTruncated(): | |
| 291 | - | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions())) | |
| 290 | + | case f.Truncated: | |
| 291 | + | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.Additions+f.Deletions)) | |
| 292 | 292 | } | |
| 293 | - | if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() { | |
| 294 | - | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode())) | |
| 293 | + | if f.OldMode != 0 && f.NewMode != 0 && f.OldMode != f.NewMode { | |
| 294 | + | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.OldMode, f.NewMode)) | |
| 295 | 295 | } | |
| 296 | - | switch f.GetStatus() { | |
| 297 | - | case pb.FileStatus_FILE_STATUS_RENAMED: | |
| 298 | - | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity())) | |
| 299 | - | case pb.FileStatus_FILE_STATUS_COPIED: | |
| 300 | - | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity())) | |
| 301 | - | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | |
| 296 | + | switch f.Status { | |
| 297 | + | case domain.FileRenamed: | |
| 298 | + | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.Similarity)) | |
| 299 | + | case domain.FileCopied: | |
| 300 | + | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.Similarity)) | |
| 301 | + | case domain.FileTypeChanged: | |
| 302 | 302 | parts = append(parts, "type changed") | |
| 303 | 303 | } | |
| 304 | 304 | return strings.Join(parts, " · ") | |
M internal/adapters/web/diff.templ +3 -4
| @@ -106,8 +106,8 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||
| 106 | 106 | aria-current="true" | |
| 107 | 107 | } | |
| 108 | 108 | if p.Both { | |
| 109 | - | hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" | |
| 110 | - | :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" } | |
| 109 | + | data-fire={ view } | |
| 110 | + | :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false" | |
| 111 | 111 | } | |
| 112 | 112 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | |
| 113 | 113 | } | |
| @@ -115,7 +115,7 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||
| 115 | 115 | // collapseButton folds every file of the diff, or opens them all again when | |
| 116 | 116 | // none is open; its label follows the state. | |
| 117 | 117 | templ collapseButton() { | |
| 118 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 118 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 119 | 119 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span><kbd aria-hidden="true">x</kbd> | |
| 120 | 120 | </button> | |
| 121 | 121 | } | |
| @@ -142,7 +142,6 @@ templ FileList(files []DiffFile) { | |||
| 142 | 142 | <li> | |
| 143 | 143 | <a | |
| 144 | 144 | href={ templ.SafeURL("#" + f.ID) } | |
| 145 | - | :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" } | |
| 146 | 145 | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | |
| 147 | 146 | > | |
| 148 | 147 | <span class="st" title={ f.Status }>{ f.Status }</span> | |
M internal/adapters/web/diff_templ.go +199 -212 generated file: collapsed
M internal/adapters/web/diff_test.go +43 -16
| @@ -10,13 +10,14 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // Trojan Source (ADR 0017): direction controls, zero-width and control | |
| 17 | 17 | // characters are shown as markers; ordinary text, tabs and non-Latin letters | |
| 18 | 18 | // are not touched. | |
| 19 | 19 | func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |
| 20 | + | t.Parallel() | |
| 20 | 21 | tests := []struct { | |
| 21 | 22 | name, in string | |
| 22 | 23 | want []Seg | |
| @@ -35,6 +36,7 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |||
| 35 | 36 | } | |
| 36 | 37 | for _, tt := range tests { | |
| 37 | 38 | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 38 | 40 | assert.Equal(t, tt.want, segments(tt.in)) | |
| 39 | 41 | }) | |
| 40 | 42 | } | |
| @@ -43,17 +45,18 @@ func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |||
| 43 | 45 | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | - | func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line { | |
| 47 | - | return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} | |
| 48 | + | func line(kind domain.LineKind, oldN, newN int, text string) domain.Line { | |
| 49 | + | return domain.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} | |
| 48 | 50 | } | |
| 49 | 51 | ||
| 50 | 52 | // The split view lines a run of deletions up with the additions after it; | |
| 51 | 53 | // context fills both sides; the extra lines of the longer run have an empty | |
| 52 | 54 | // opposite side. | |
| 53 | 55 | func TestPairLines(t *testing.T) { | |
| 54 | - | ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION | |
| 56 | + | t.Parallel() | |
| 57 | + | ctx, add, del := domain.LineContext, domain.LineAddition, domain.LineDeletion | |
| 55 | 58 | var ls []DiffLine | |
| 56 | - | for _, l := range []*pb.Line{ | |
| 59 | + | for _, l := range []domain.Line{ | |
| 57 | 60 | line(ctx, 1, 1, "c"), | |
| 58 | 61 | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | |
| 59 | 62 | line(add, 0, 2, "a1"), | |
| @@ -83,6 +86,7 @@ func TestPairLines(t *testing.T) { | |||
| 83 | 86 | } | |
| 84 | 87 | ||
| 85 | 88 | func TestChangeBar(t *testing.T) { | |
| 89 | + | t.Parallel() | |
| 86 | 90 | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | |
| 87 | 91 | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | |
| 88 | 92 | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | |
| @@ -91,19 +95,41 @@ func TestChangeBar(t *testing.T) { | |||
| 91 | 95 | } | |
| 92 | 96 | ||
| 93 | 97 | func TestFileNotes(t *testing.T) { | |
| 94 | - | assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED})) | |
| 95 | - | assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755})) | |
| 96 | - | assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change") | |
| 97 | - | assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644})) | |
| 98 | - | assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large") | |
| 98 | + | t.Parallel() | |
| 99 | + | tests := []struct { | |
| 100 | + | name string | |
| 101 | + | file domain.FileDiff | |
| 102 | + | want string // a substring; empty means no note at all | |
| 103 | + | eq bool // want is the whole note | |
| 104 | + | }{ | |
| 105 | + | {"binary", domain.FileDiff{Binary: true, Status: domain.FileModified}, "binary file: no text diff", true}, | |
| 106 | + | {"mode change", domain.FileDiff{Status: domain.FileModified, OldMode: 0o100644, NewMode: 0o100755}, "mode 100644 → 100755", true}, | |
| 107 | + | {"an added file has no mode change", domain.FileDiff{Status: domain.FileAdded, NewMode: 0o100644}, "", true}, | |
| 108 | + | {"rename", domain.FileDiff{Status: domain.FileRenamed, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644}, "renamed, 90% similar", true}, | |
| 109 | + | {"copy", domain.FileDiff{Status: domain.FileCopied, Similarity: 80}, "copied, 80% similar", true}, | |
| 110 | + | {"type change", domain.FileDiff{Status: domain.FileTypeChanged}, "type changed", true}, | |
| 111 | + | {"truncated", domain.FileDiff{Truncated: true, Additions: 2500}, "too large", false}, | |
| 112 | + | } | |
| 113 | + | for _, tt := range tests { | |
| 114 | + | t.Run(tt.name, func(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 116 | + | got := fileNote(tt.file) | |
| 117 | + | if tt.eq { | |
| 118 | + | assert.Equal(t, tt.want, got) | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | assert.Contains(t, got, tt.want) | |
| 122 | + | }) | |
| 123 | + | } | |
| 99 | 124 | } | |
| 100 | 125 | ||
| 101 | 126 | func TestDiffFilesPaths(t *testing.T) { | |
| 102 | - | fs := diffFiles([]*pb.FileDiff{ | |
| 103 | - | {Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}}, | |
| 104 | - | {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1}, | |
| 105 | - | {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"}, | |
| 106 | - | {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000}, | |
| 127 | + | t.Parallel() | |
| 128 | + | fs := diffFiles([]domain.FileDiff{ | |
| 129 | + | {Status: domain.FileAdded, NewPath: "a.go", Additions: 1, Hunks: []domain.Hunk{{Lines: []domain.Line{line(domain.LineAddition, 0, 1, "x")}}}}, | |
| 130 | + | {Status: domain.FileDeleted, OldPath: "b.go", Deletions: 1}, | |
| 131 | + | {Status: domain.FileRenamed, OldPath: "c\u202e.go", NewPath: "d.go"}, | |
| 132 | + | {Status: domain.FileModified, NewPath: "big.go", Truncated: true, Additions: 3000}, | |
| 107 | 133 | }) | |
| 108 | 134 | assert.Equal(t, "a.go", fs[0].Path) | |
| 109 | 135 | assert.True(t, fs[0].Open) | |
| @@ -117,8 +143,9 @@ func TestDiffFilesPaths(t *testing.T) { | |||
| 117 | 143 | // A code cell holds exactly the text: escaped, with markers, and nothing | |
| 118 | 144 | // added between the pieces (the cell keeps its white space). | |
| 119 | 145 | func TestCodeLine(t *testing.T) { | |
| 146 | + | t.Parallel() | |
| 120 | 147 | var b strings.Builder | |
| 121 | - | ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEof: true}) | |
| 148 | + | ln := diffLine(domain.Line{Kind: domain.LineAddition, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEOF: true}) | |
| 122 | 149 | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | |
| 123 | 150 | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | |
| 124 | 151 | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | |
M internal/adapters/web/errors_test.go +143 -48
| @@ -6,41 +6,27 @@ import ( | |||
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | + | "fmt" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "net/http/httptest" | |
| 11 | 12 | "regexp" | |
| 12 | 13 | "testing" | |
| 13 | 14 | ||
| 14 | - | "connectrpc.com/connect" | |
| 15 | 15 | "github.com/bnema/zerowrap" | |
| 16 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 17 | 18 | "github.com/stretchr/testify/require" | |
| 18 | 19 | ||
| 19 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 20 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | - | // failing is a repository client that fails every call with err. | |
| 24 | - | type failing struct { | |
| 25 | - | ermiteconnect.RepoServiceClient | |
| 26 | - | err error | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func (f failing) ListRepos(context.Context, *connect.Request[pb.ListReposRequest]) (*connect.Response[pb.ListReposResponse], error) { | |
| 30 | - | return nil, f.err | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | func (f failing) GetRepo(context.Context, *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |
| 34 | - | return nil, f.err | |
| 35 | - | } | |
| 36 | - | ||
| 25 | + | // serve asks the web for path while every read of a repository fails with err, | |
| 26 | + | // and returns the answer and what was logged. | |
| 37 | 27 | func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, string) { | |
| 38 | 28 | t.Helper() | |
| 39 | - | srv, nerr := New(Config{ | |
| 40 | - | Repos: failing{err: err}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 41 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 42 | - | }) | |
| 43 | - | require.NoError(t, nerr) | |
| 29 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, err), Browse: mocks.NewBrowseService(t)}) | |
| 44 | 30 | var logs bytes.Buffer | |
| 45 | 31 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs})) | |
| 46 | 32 | rec := httptest.NewRecorder() | |
| @@ -48,37 +34,146 @@ func serve(t *testing.T, err error, path string) (*httptest.ResponseRecorder, st | |||
| 48 | 34 | return rec, logs.String() | |
| 49 | 35 | } | |
| 50 | 36 | ||
| 51 | - | // Errors of the API: not found (and an invalid name) are the 404 page, any | |
| 52 | - | // other is the 500 page with an id that the log carries. | |
| 37 | + | // Errors of the use cases: not found (and an invalid name) are the 404 page, a | |
| 38 | + | // visitor that left gets nothing, any other is the 500 page with an id that | |
| 39 | + | // the log carries. | |
| 53 | 40 | func TestErrorPages(t *testing.T) { | |
| 54 | - | rec, logs := serve(t, connect.NewError(connect.CodeNotFound, errors.New("repository not found")), "/gone") | |
| 55 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 56 | - | assert.Contains(t, rec.Body.String(), "not found") | |
| 57 | - | assert.Empty(t, logs, "a 404 is not an error") | |
| 41 | + | t.Parallel() | |
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | err error | |
| 45 | + | path string | |
| 46 | + | want int | |
| 47 | + | }{ | |
| 48 | + | {"not found", fmt.Errorf("repository: %w", domain.ErrNotFound), "/gone", http.StatusNotFound}, | |
| 49 | + | {"invalid repository name", domain.ErrInvalidName, "/Bad", http.StatusNotFound}, | |
| 50 | + | {"invalid argument", domain.ErrInvalid, "/demo", http.StatusNotFound}, | |
| 51 | + | {"invalid page token", domain.ErrInvalidPageToken, "/demo", http.StatusNotFound}, | |
| 52 | + | {"invalid revision", domain.ErrInvalidRevision, "/demo", http.StatusNotFound}, | |
| 53 | + | {"invalid path", domain.ErrInvalidPath, "/demo", http.StatusNotFound}, | |
| 54 | + | {"internal", errors.New("dial tcp 10.0.0.5: kv exploded"), "/demo", http.StatusInternalServerError}, | |
| 55 | + | {"internal on the home page", errors.New("kv exploded on the list"), "/", http.StatusInternalServerError}, | |
| 56 | + | {"forbidden is not a page of its own", domain.ErrForbidden, "/demo", http.StatusInternalServerError}, | |
| 57 | + | {"resource exhausted", domain.ErrResourceExhausted, "/demo", http.StatusInternalServerError}, | |
| 58 | + | {"deadline", context.DeadlineExceeded, "/demo", http.StatusInternalServerError}, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | t.Parallel() | |
| 63 | + | rec, logs := serve(t, tt.err, tt.path) | |
| 64 | + | assert.Equal(t, tt.want, rec.Code) | |
| 65 | + | if tt.want == http.StatusNotFound { | |
| 66 | + | assert.Contains(t, rec.Body.String(), "not found") | |
| 67 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays out of the page") | |
| 68 | + | assert.Empty(t, logs, "a 404 is not an error") | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 72 | + | assert.Contains(t, rec.Body.String(), "internal error") | |
| 73 | + | assert.NotContains(t, rec.Body.String(), tt.err.Error(), "the cause stays in the log") | |
| 74 | + | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |
| 75 | + | require.Len(t, m, 2, "the page shows a request id") | |
| 76 | + | assert.Contains(t, logs, m[1], "the log carries the same id") | |
| 77 | + | assert.Contains(t, logs, tt.err.Error()) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | } | |
| 58 | 81 | ||
| 59 | - | rec, _ = serve(t, connect.NewError(connect.CodeInvalidArgument, errors.New("bad name")), "/Bad") | |
| 60 | - | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 61 | - | assert.NotContains(t, rec.Body.String(), "bad name") | |
| 82 | + | // A visitor that left gets no page at all. | |
| 83 | + | func TestCanceledRequestGetsNoPage(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 85 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, context.Canceled), Browse: mocks.NewBrowseService(t)}) | |
| 86 | + | rec := httptest.NewRecorder() | |
| 87 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/demo", nil)) | |
| 88 | + | assert.Equal(t, http.StatusOK, rec.Code, "nothing was written") | |
| 89 | + | assert.Empty(t, rec.Body.String()) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // The caller of every use case call is the principal of the request: anonymous | |
| 93 | + | // without a valid session, the owner with one. A private repository is as | |
| 94 | + | // missing for the first as one that does not exist (the 404 page, never a 403). | |
| 95 | + | func TestUseCasesGetThePrincipalOfTheRequest(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | const token = "tok" | |
| 98 | + | tests := []struct { | |
| 99 | + | name string | |
| 100 | + | cookie string | |
| 101 | + | want domain.Principal | |
| 102 | + | }{ | |
| 103 | + | {"no cookie is anonymous", "", domain.Anonymous()}, | |
| 104 | + | {"an invalid session is anonymous", "bad", domain.Anonymous()}, | |
| 105 | + | {"a valid session is the owner", token, domain.Owner()}, | |
| 106 | + | } | |
| 107 | + | for _, tt := range tests { | |
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |
| 109 | + | t.Parallel() | |
| 110 | + | repos := mocks.NewRepoService(t) | |
| 111 | + | // The use case answers with the principal it was given: only the | |
| 112 | + | // owner sees the private repository. | |
| 113 | + | repos.EXPECT().Get(mock.Anything, tt.want, repoName(t, "secret")).RunAndReturn( | |
| 114 | + | func(_ context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 115 | + | if !p.IsRoot() { | |
| 116 | + | return domain.Repo{}, domain.ErrNotFound | |
| 117 | + | } | |
| 118 | + | return domain.Repo{Name: name, Visibility: domain.VisibilityPrivate}, nil | |
| 119 | + | }).Once() | |
| 120 | + | if tt.want.IsOwner() { | |
| 121 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "secret"), nil).Once() | |
| 122 | + | repos.EXPECT().ListPage(mock.Anything, tt.want, mock.Anything, mock.Anything).Return(ports.RepoPage{}, nil).Once() | |
| 123 | + | } | |
| 124 | + | browse := mocks.NewBrowseService(t) | |
| 125 | + | if tt.want.IsOwner() { | |
| 126 | + | browse.EXPECT().ListRefs(mock.Anything, tt.want, repoName(t, "secret"), mock.Anything).Return(ports.RefPage{}, nil).Once() | |
| 127 | + | } | |
| 128 | + | srv := newTestServer(t, Config{Repos: repos, Browse: browse, Sessions: newSessions(t, token), Limiter: &countingLimiter{}}) | |
| 129 | + | ||
| 130 | + | req := httptest.NewRequest(http.MethodGet, "/secret/refs", nil) | |
| 131 | + | if tt.cookie != "" { | |
| 132 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: tt.cookie}) | |
| 133 | + | } | |
| 134 | + | rec := httptest.NewRecorder() | |
| 135 | + | srv.ServeHTTP(rec, req) | |
| 62 | 136 | ||
| 63 | - | secret := "dial tcp 10.0.0.5: kv exploded" | |
| 64 | - | rec, logs = serve(t, connect.NewError(connect.CodeInternal, errors.New(secret)), "/demo") | |
| 65 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 66 | - | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 67 | - | assert.Contains(t, rec.Body.String(), "internal error") | |
| 68 | - | assert.NotContains(t, rec.Body.String(), secret, "the cause stays in the log") | |
| 69 | - | m := regexp.MustCompile(`request id: ([0-9a-f]{8})`).FindStringSubmatch(rec.Body.String()) | |
| 70 | - | require.Len(t, m, 2, "the page shows a request id") | |
| 71 | - | assert.Contains(t, logs, m[1], "the log carries the same id") | |
| 72 | - | assert.Contains(t, logs, "kv exploded") | |
| 137 | + | if tt.want.IsOwner() { | |
| 138 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | assert.Equal(t, http.StatusNotFound, rec.Code, "a private repository is a missing one") | |
| 142 | + | assert.Contains(t, rec.Body.String(), "not found") | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // A request that arrives with a principal in its context is anonymous anyway: | |
| 148 | + | // only a valid session makes the owner. | |
| 149 | + | func TestRequestContextCannotSetThePrincipal(t *testing.T) { | |
| 150 | + | t.Parallel() | |
| 151 | + | repos := mocks.NewRepoService(t) | |
| 152 | + | repos.EXPECT().Get(mock.Anything, domain.Anonymous(), repoName(t, "secret")).Return(domain.Repo{}, domain.ErrNotFound).Once() | |
| 153 | + | srv := newTestServer(t, Config{Repos: repos, Browse: mocks.NewBrowseService(t)}) | |
| 73 | 154 | ||
| 74 | - | rec, _ = serve(t, connect.NewError(connect.CodeInternal, errors.New("x")), "/") | |
| 75 | - | assert.Equal(t, http.StatusInternalServerError, rec.Code, "the home page too") | |
| 155 | + | ctx := domain.ContextWithPrincipal(context.Background(), domain.Root()) | |
| 156 | + | rec := httptest.NewRecorder() | |
| 157 | + | srv.ServeHTTP(rec, httptest.NewRequestWithContext(ctx, http.MethodGet, "/secret", nil)) | |
| 158 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 76 | 159 | } | |
| 77 | 160 | ||
| 78 | 161 | func TestNewValidates(t *testing.T) { | |
| 79 | - | _, err := New(Config{PublicURL: "https://forge.example"}) | |
| 80 | - | assert.Error(t, err, "clients are required") | |
| 81 | - | git := ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid") | |
| 82 | - | _, err = New(Config{Repos: failing{}, Git: git, PublicURL: "not a url"}) | |
| 83 | - | assert.Error(t, err) | |
| 162 | + | t.Parallel() | |
| 163 | + | tests := []struct { | |
| 164 | + | name string | |
| 165 | + | cfg Config | |
| 166 | + | }{ | |
| 167 | + | {"use cases are required", Config{PublicURL: testPublicURL}}, | |
| 168 | + | {"browse is required", Config{Repos: mocks.NewRepoService(t), PublicURL: testPublicURL}}, | |
| 169 | + | {"repos are required", Config{Browse: mocks.NewBrowseService(t), PublicURL: testPublicURL}}, | |
| 170 | + | {"the public URL must be one", Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t), PublicURL: "not a url"}}, | |
| 171 | + | } | |
| 172 | + | for _, tt := range tests { | |
| 173 | + | t.Run(tt.name, func(t *testing.T) { | |
| 174 | + | t.Parallel() | |
| 175 | + | _, err := New(tt.cfg) | |
| 176 | + | assert.Error(t, err) | |
| 177 | + | }) | |
| 178 | + | } | |
| 84 | 179 | } | |
M internal/adapters/web/events.go +74 -45
| @@ -5,22 +5,25 @@ package web | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | + | "fmt" | |
| 8 | 9 | "io" | |
| 9 | 10 | "net/http" | |
| 10 | 11 | "net/url" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 14 | + | "sync" | |
| 13 | 15 | "time" | |
| 14 | 16 | ||
| 15 | - | "connectrpc.com/connect" | |
| 16 | 17 | "github.com/bnema/zerowrap" | |
| 17 | 18 | ||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 19 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | 22 | ) | |
| 20 | 23 | ||
| 21 | 24 | // Live updates. A page that can change under the eyes of its reader opens | |
| 22 | 25 | // GET /-/events (the Live component of the hint badge). The handler follows | |
| 23 | - | // EventService.Watch as the visitor of the request and turns each event into | |
| 26 | + | // EventService.Watch as the principal of the request and turns each event into | |
| 24 | 27 | // the HTML fragments the page shows, sent as Server-Sent Events: | |
| 25 | 28 | // | |
| 26 | 29 | // GET /-/events?topic=home | |
| @@ -51,7 +54,10 @@ import ( | |||
| 51 | 54 | // changed: the page fetches itself again (ermiteLive in ermite.js) | |
| 52 | 55 | // | |
| 53 | 56 | // The data of an event is one fragment, a top-level element with an id, one | |
| 54 | - | // "data:" line per line of HTML; the page swaps the element that has that id. | |
| 57 | + | // "data:" line per line of HTML; the page swaps the element that has that id | |
| 58 | + | // (ermiteLive.swap). The slots of the banners (commits-head, mr-notice) are | |
| 59 | + | // status regions that stay in the page: only their children are replaced, so | |
| 60 | + | // that a screen reader announces the banner. | |
| 55 | 61 | // An event that changes nothing on the page is still sent as an id alone, so | |
| 56 | 62 | // that a reconnection resumes after it. | |
| 57 | 63 | ||
| @@ -60,10 +66,6 @@ import ( | |||
| 60 | 66 | const DefaultHeartbeat = 25 * time.Second | |
| 61 | 67 | ||
| 62 | 68 | const ( | |
| 63 | - | // startGrace is how long the handler waits after the Watch call answered | |
| 64 | - | // for an early failure (too many watchers) before it commits to 200: the | |
| 65 | - | // Connect protocol reports it in the stream, after the headers. | |
| 66 | - | startGrace = 100 * time.Millisecond | |
| 67 | 69 | // writeWindow bounds one write to the visitor, whatever the write timeout | |
| 68 | 70 | // of the server is. | |
| 69 | 71 | writeWindow = 30 * time.Second | |
| @@ -102,7 +104,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 102 | 104 | if repo != "" { | |
| 103 | 105 | // A repository the visitor cannot see is a missing one (AC2). | |
| 104 | 106 | cctx, ccancel := context.WithTimeout(ctx, requestTimeout) | |
| 105 | - | _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo})) | |
| 107 | + | err := s.canSee(cctx, repo) | |
| 106 | 108 | ccancel() | |
| 107 | 109 | if err != nil { | |
| 108 | 110 | s.denyEvents(ctx, w, err) | |
| @@ -110,29 +112,44 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 110 | 112 | } | |
| 111 | 113 | } | |
| 112 | 114 | ||
| 113 | - | stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{ | |
| 115 | + | // The watch runs in a goroutine and hands each event to this one through | |
| 116 | + | // an unbuffered channel: a visitor that reads slowly holds the watch in | |
| 117 | + | // its send, falls behind the buffer of the use case, and gets a gap. | |
| 118 | + | msgs := make(chan recvd) | |
| 119 | + | started := make(chan struct{}) | |
| 120 | + | var startOnce sync.Once | |
| 121 | + | start := func() { startOnce.Do(func() { close(started) }) } | |
| 122 | + | finished := make(chan struct{}) | |
| 123 | + | params := ports.WatchParams{ | |
| 114 | 124 | Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"), | |
| 115 | - | })) | |
| 116 | - | if err != nil { | |
| 117 | - | s.denyEvents(ctx, w, err) | |
| 118 | - | return | |
| 125 | + | // The visitor is the client of the watch, for the limit per client. | |
| 126 | + | Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)), | |
| 127 | + | Ready: start, | |
| 119 | 128 | } | |
| 120 | - | // Wait for the answer of the API, which comes once the watch is registered | |
| 121 | - | // (or refused), then read the stream from a goroutine. | |
| 122 | - | _ = stream.ResponseHeader() | |
| 123 | - | msgs := make(chan recvd) | |
| 124 | - | done := make(chan struct{}) | |
| 125 | 129 | go func() { | |
| 126 | - | defer close(done) | |
| 130 | + | defer close(finished) | |
| 127 | 131 | defer close(msgs) | |
| 128 | - | for stream.Receive() { | |
| 132 | + | // net/http only recovers the handler goroutine: a panic in the watch | |
| 133 | + | // would end the process. It ends this stream as an error instead. | |
| 134 | + | defer func() { | |
| 135 | + | if v := recover(); v != nil { | |
| 136 | + | log.Error().Interface("panic", v).Msg("event watch panicked") | |
| 137 | + | select { | |
| 138 | + | case msgs <- recvd{err: fmt.Errorf("watch panicked: %v", v)}: | |
| 139 | + | case <-ctx.Done(): | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | }() | |
| 143 | + | err := s.cfg.Events.Watch(ctx, principal(ctx), params, func(we ports.WatchEvent) error { | |
| 144 | + | start() | |
| 129 | 145 | select { | |
| 130 | - | case msgs <- recvd{ev: stream.Msg().GetEvent()}: | |
| 146 | + | case msgs <- recvd{ev: we}: | |
| 147 | + | return nil | |
| 131 | 148 | case <-ctx.Done(): | |
| 132 | - | return | |
| 149 | + | return ctx.Err() | |
| 133 | 150 | } | |
| 134 | - | } | |
| 135 | - | if err := stream.Err(); err != nil { | |
| 151 | + | }) | |
| 152 | + | if err != nil && ctx.Err() == nil { | |
| 136 | 153 | select { | |
| 137 | 154 | case msgs <- recvd{err: err}: | |
| 138 | 155 | case <-ctx.Done(): | |
| @@ -141,26 +158,24 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 141 | 158 | }() | |
| 142 | 159 | defer func() { | |
| 143 | 160 | cancel() | |
| 144 | - | _ = stream.Close() | |
| 145 | - | <-done | |
| 161 | + | <-finished | |
| 146 | 162 | }() | |
| 147 | 163 | ||
| 164 | + | // The watch is registered (or refused, which ends it before): only then | |
| 165 | + | // does the answer commit to 200. | |
| 148 | 166 | var first *recvd | |
| 149 | - | grace := time.NewTimer(startGrace) | |
| 150 | 167 | select { | |
| 168 | + | case <-started: | |
| 151 | 169 | case m, open := <-msgs: | |
| 152 | - | grace.Stop() | |
| 153 | 170 | if !open { | |
| 154 | 171 | return // the server is stopping | |
| 155 | 172 | } | |
| 173 | + | if m.err != nil { | |
| 174 | + | s.denyEvents(ctx, w, m.err) | |
| 175 | + | return | |
| 176 | + | } | |
| 156 | 177 | first = &m | |
| 157 | - | case <-grace.C: | |
| 158 | 178 | case <-ctx.Done(): | |
| 159 | - | grace.Stop() | |
| 160 | - | return | |
| 161 | - | } | |
| 162 | - | if first != nil && first.err != nil { | |
| 163 | - | s.denyEvents(ctx, w, first.err) | |
| 164 | 179 | return | |
| 165 | 180 | } | |
| 166 | 181 | ||
| @@ -182,11 +197,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 182 | 197 | st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")} | |
| 183 | 198 | handle := func(m recvd) bool { | |
| 184 | 199 | if m.err != nil { | |
| 185 | - | if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled { | |
| 200 | + | if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) { | |
| 186 | 201 | log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended") | |
| 187 | 202 | } | |
| 188 | 203 | return false | |
| 189 | 204 | } | |
| 205 | + | if !liveEvent(m.ev) { | |
| 206 | + | return true // nothing to show, not even an id to resume after | |
| 207 | + | } | |
| 190 | 208 | // An owner's stream ends when the session does, before it sends | |
| 191 | 209 | // anything more: an event can carry what only the owner may see, and | |
| 192 | 210 | // waiting for the next heartbeat would leave a window. | |
| @@ -205,7 +223,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 205 | 223 | log.Warn().Err(err).Str("topic", topic).Msg("render live fragment") | |
| 206 | 224 | frags = gap | |
| 207 | 225 | } | |
| 208 | - | return out.send(m.ev.GetId(), frags) == nil | |
| 226 | + | return out.send(m.ev.ID, frags) == nil | |
| 209 | 227 | } | |
| 210 | 228 | if first != nil && !handle(*first) { | |
| 211 | 229 | return | |
| @@ -240,30 +258,41 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 240 | 258 | ||
| 241 | 259 | // recvd is a message of the Watch stream, or how it failed. | |
| 242 | 260 | type recvd struct { | |
| 243 | - | ev *pb.Event | |
| 261 | + | ev ports.WatchEvent | |
| 244 | 262 | err error | |
| 245 | 263 | } | |
| 246 | 264 | ||
| 265 | + | // canSee checks that the caller of the request may see the repository named in | |
| 266 | + | // a topic: one it may not is as missing as one that does not exist. | |
| 267 | + | func (s *Server) canSee(ctx context.Context, rawName string) error { | |
| 268 | + | name, err := domain.ParseRepoName(rawName) | |
| 269 | + | if err != nil { | |
| 270 | + | return err | |
| 271 | + | } | |
| 272 | + | _, err = s.cfg.Repos.Get(ctx, principal(ctx), name) | |
| 273 | + | return err | |
| 274 | + | } | |
| 275 | + | ||
| 247 | 276 | // denyStream answers a request that gets no stream. | |
| 248 | 277 | func denyStream(w http.ResponseWriter, status int, msg string) { | |
| 249 | 278 | w.Header().Set("Cache-Control", "no-store") | |
| 250 | 279 | http.Error(w, msg, status) | |
| 251 | 280 | } | |
| 252 | 281 | ||
| 253 | - | // denyEvents maps an error of the API, before the stream started, to a | |
| 282 | + | // denyEvents maps an error of a use case, before the stream started, to a | |
| 254 | 283 | // status. Not found and invalid are 404 whatever the reason: a private | |
| 255 | 284 | // repository is a missing one. | |
| 256 | 285 | func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) { | |
| 257 | - | switch code := connect.CodeOf(err); { | |
| 258 | - | case code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |
| 286 | + | switch { | |
| 287 | + | case errors.Is(err, context.Canceled): | |
| 288 | + | // The visitor is gone. | |
| 289 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |
| 259 | 290 | denyStream(w, http.StatusNotFound, "not found") | |
| 260 | - | case code == connect.CodeResourceExhausted: | |
| 291 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 261 | 292 | w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) | |
| 262 | 293 | denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later") | |
| 263 | - | case code == connect.CodeUnauthenticated: | |
| 294 | + | case errors.Is(err, domain.ErrUnauthenticated): | |
| 264 | 295 | denyStream(w, http.StatusUnauthorized, "unauthorized") | |
| 265 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 266 | - | // The visitor is gone. | |
| 267 | 296 | default: | |
| 268 | 297 | log := zerowrap.FromCtx(ctx) | |
| 269 | 298 | log.Error().Err(err).Msg("open event stream") | |
M internal/adapters/web/events_test.go +155 -96
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "bufio" | |
| 7 | 7 | "context" | |
| 8 | 8 | "errors" | |
| 9 | + | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "net/http/httptest" | |
| @@ -14,15 +15,17 @@ import ( | |||
| 14 | 15 | "testing" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 17 | - | "connectrpc.com/connect" | |
| 18 | 18 | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 19 | 20 | "github.com/stretchr/testify/require" | |
| 20 | 21 | ||
| 21 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 22 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | + | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 24 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | 27 | func TestFormatEvent(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 26 | 29 | assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n", | |
| 27 | 30 | formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line") | |
| 28 | 31 | assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true)) | |
| @@ -32,6 +35,7 @@ func TestFormatEvent(t *testing.T) { | |||
| 32 | 35 | } | |
| 33 | 36 | ||
| 34 | 37 | func TestParseTopic(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 35 | 39 | for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} { | |
| 36 | 40 | repo, ok := parseTopic(in) | |
| 37 | 41 | assert.True(t, ok, in) | |
| @@ -44,38 +48,57 @@ func TestParseTopic(t *testing.T) { | |||
| 44 | 48 | } | |
| 45 | 49 | ||
| 46 | 50 | func TestLiveURL(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 47 | 52 | assert.Equal(t, "/-/events?topic=home", liveURL("home", "")) | |
| 48 | 53 | assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs")) | |
| 49 | 54 | } | |
| 50 | 55 | ||
| 51 | 56 | func TestNewCommitsText(t *testing.T) { | |
| 57 | + | t.Parallel() | |
| 52 | 58 | assert.Equal(t, "1 new commit — reload", newCommits("1", 1)) | |
| 53 | 59 | assert.Equal(t, "3 new commits — reload", newCommits("3", 3)) | |
| 54 | 60 | assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2)) | |
| 55 | 61 | } | |
| 56 | 62 | ||
| 57 | - | // fakeEvents is an EventService whose behaviour a test sets. | |
| 58 | - | type fakeEvents struct { | |
| 59 | - | ermiteconnect.UnimplementedEventServiceHandler | |
| 63 | + | // watchProbe drives the generated EventService mock: what the watch does, and | |
| 64 | + | // a record of the principal and the parameters of the watches it was asked for. | |
| 65 | + | type watchProbe struct { | |
| 60 | 66 | err error | |
| 61 | - | events []*pb.Event | |
| 67 | + | events []ports.WatchEvent | |
| 62 | 68 | opened atomic.Int32 | |
| 63 | 69 | closed atomic.Int32 | |
| 64 | - | resume atomic.Value | |
| 70 | + | resume atomic.Value // string: ResumeAfter of the last watch | |
| 71 | + | topics atomic.Value // []string: Topics of the last watch | |
| 72 | + | client atomic.Value // string: Client of the last watch | |
| 73 | + | who atomic.Value // domain.Principal: caller of the last watch | |
| 65 | 74 | release chan struct{} | |
| 66 | - | push chan *pb.Event // events sent on demand, after the fixed ones | |
| 75 | + | push chan ports.WatchEvent // events sent on demand, after the fixed ones | |
| 67 | 76 | } | |
| 68 | 77 | ||
| 69 | - | func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error { | |
| 78 | + | // service is the EventService the web watches: the mock, answering with the | |
| 79 | + | // probe. | |
| 80 | + | func (f *watchProbe) service(t testing.TB) *mocks.EventService { | |
| 81 | + | t.Helper() | |
| 82 | + | m := mocks.NewEventService(t) | |
| 83 | + | m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(f.run).Maybe() | |
| 84 | + | return m | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (f *watchProbe) run(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error { | |
| 70 | 88 | f.opened.Add(1) | |
| 71 | 89 | defer f.closed.Add(1) | |
| 72 | - | f.resume.Store(req.Msg.GetResumeAfter()) | |
| 90 | + | f.resume.Store(params.ResumeAfter) | |
| 91 | + | f.topics.Store(params.Topics) | |
| 92 | + | f.client.Store(params.Client) | |
| 93 | + | f.who.Store(p) | |
| 73 | 94 | if f.err != nil { | |
| 74 | 95 | return f.err | |
| 75 | 96 | } | |
| 76 | - | _ = st.Send(nil) | |
| 97 | + | if params.Ready != nil { | |
| 98 | + | params.Ready() | |
| 99 | + | } | |
| 77 | 100 | for _, ev := range f.events { | |
| 78 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | |
| 101 | + | if err := send(ev); err != nil { | |
| 79 | 102 | return err | |
| 80 | 103 | } | |
| 81 | 104 | } | |
| @@ -86,46 +109,29 @@ func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchReq | |||
| 86 | 109 | case <-f.release: | |
| 87 | 110 | return nil | |
| 88 | 111 | case ev := <-f.push: | |
| 89 | - | if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil { | |
| 112 | + | if err := send(ev); err != nil { | |
| 90 | 113 | return err | |
| 91 | 114 | } | |
| 92 | 115 | } | |
| 93 | 116 | } | |
| 94 | 117 | } | |
| 95 | 118 | ||
| 96 | - | // repoOK answers every GetRepo with a public repository. | |
| 97 | - | type repoOK struct { | |
| 98 | - | ermiteconnect.UnimplementedRepoServiceHandler | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) { | |
| 102 | - | return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // eventsServer serves the web on a fake API, with a write timeout shorter | |
| 106 | - | // than the heartbeat, like a server that sets one. | |
| 107 | - | func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |
| 119 | + | // eventsServer serves the web on a fake EventService, with a write timeout | |
| 120 | + | // shorter than the heartbeat, like a server that sets one. | |
| 121 | + | func eventsServer(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration) *httptest.Server { | |
| 108 | 122 | t.Helper() | |
| 109 | 123 | return eventsServerWith(t, f, beat, writeTimeout, nil) | |
| 110 | 124 | } | |
| 111 | 125 | ||
| 112 | 126 | // eventsServerWith is eventsServer with sessions, when sess is not nil. | |
| 113 | - | func eventsServerWith(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration, sess SessionAuth) *httptest.Server { | |
| 127 | + | func eventsServerWith(t *testing.T, f *watchProbe, beat time.Duration, writeTimeout time.Duration, sess ports.WebSessionService) *httptest.Server { | |
| 114 | 128 | t.Helper() | |
| 115 | - | api := http.NewServeMux() | |
| 116 | - | api.Handle(ermiteconnect.NewEventServiceHandler(f)) | |
| 117 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | |
| 118 | - | backend := httptest.NewServer(api) | |
| 119 | - | t.Cleanup(backend.Close) | |
| 120 | - | ||
| 121 | - | srv, err := New(Config{ | |
| 122 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |
| 123 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |
| 124 | - | Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL), | |
| 125 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 129 | + | srv := newTestServer(t, Config{ | |
| 130 | + | Repos: reposServing(t, publicRepo(t, "x")), | |
| 131 | + | Browse: mocks.NewBrowseService(t), | |
| 132 | + | Events: f.service(t), | |
| 126 | 133 | Heartbeat: beat, Sessions: sess, Limiter: limiterFor(sess), | |
| 127 | 134 | }) | |
| 128 | - | require.NoError(t, err) | |
| 129 | 135 | ts := httptest.NewUnstartedServer(srv) | |
| 130 | 136 | ts.Config.WriteTimeout = writeTimeout | |
| 131 | 137 | ts.Start() | |
| @@ -150,7 +156,8 @@ func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string | |||
| 150 | 156 | // An idle stream gets a comment every heartbeat, and the write timeout of the | |
| 151 | 157 | // server does not end it. | |
| 152 | 158 | func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |
| 153 | - | f := &fakeEvents{release: make(chan struct{})} | |
| 159 | + | t.Parallel() | |
| 160 | + | f := &watchProbe{release: make(chan struct{})} | |
| 154 | 161 | ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond) | |
| 155 | 162 | resp, cancel := open(t, ts, "topic=home", nil) | |
| 156 | 163 | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| @@ -173,9 +180,10 @@ func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) { | |||
| 173 | 180 | // The id of the event, its name and the lines of its data come through; the | |
| 174 | 181 | // Last-Event-ID header is the resume point; a Gap becomes a gap event. | |
| 175 | 182 | func TestEventsStreamsEventsAndResumes(t *testing.T) { | |
| 176 | - | f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{ | |
| 177 | - | {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}}, | |
| 178 | - | {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}, | |
| 183 | + | t.Parallel() | |
| 184 | + | f := &watchProbe{release: make(chan struct{}), events: []ports.WatchEvent{ | |
| 185 | + | {ID: "b-7", Event: domain.PushEvent{Repo: repoName(t, "x")}}, | |
| 186 | + | {Gap: true}, | |
| 179 | 187 | }} | |
| 180 | 188 | ts := eventsServer(t, f, time.Hour, 0) | |
| 181 | 189 | resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"}) | |
| @@ -191,29 +199,92 @@ func TestEventsStreamsEventsAndResumes(t *testing.T) { | |||
| 191 | 199 | assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id") | |
| 192 | 200 | assert.Contains(t, lines, "event: gap") | |
| 193 | 201 | assert.Equal(t, "b-6", f.resume.Load()) | |
| 202 | + | assert.Equal(t, []string{"repo:x"}, f.topics.Load()) | |
| 203 | + | assert.Equal(t, domain.Anonymous(), f.who.Load(), "a visitor watches as anonymous") | |
| 204 | + | assert.NotEmpty(t, f.client.Load(), "the watch is attributed to the address of the visitor") | |
| 194 | 205 | } | |
| 195 | 206 | ||
| 196 | - | // Too many watchers is a 503 the browser can retry, not a stream. | |
| 197 | - | func TestEventsResourceExhausted(t *testing.T) { | |
| 198 | - | f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))} | |
| 199 | - | ts := eventsServer(t, f, time.Hour, 0) | |
| 200 | - | resp, _ := open(t, ts, "topic=home", nil) | |
| 201 | - | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |
| 202 | - | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | |
| 203 | - | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |
| 207 | + | // Too many watchers is a 503 the browser can retry, not a stream; what the | |
| 208 | + | // watch is refused for decides the status. | |
| 209 | + | func TestEventsRefusals(t *testing.T) { | |
| 210 | + | t.Parallel() | |
| 211 | + | tests := []struct { | |
| 212 | + | name string | |
| 213 | + | err error | |
| 214 | + | want int | |
| 215 | + | retryAfter bool | |
| 216 | + | }{ | |
| 217 | + | {"resource exhausted", fmt.Errorf("%w: too many watchers", domain.ErrResourceExhausted), http.StatusServiceUnavailable, true}, | |
| 218 | + | {"not found", domain.ErrNotFound, http.StatusNotFound, false}, | |
| 219 | + | {"invalid", domain.ErrInvalid, http.StatusNotFound, false}, | |
| 220 | + | {"unauthenticated", domain.ErrUnauthenticated, http.StatusUnauthorized, false}, | |
| 221 | + | {"internal", errors.New("boom"), http.StatusInternalServerError, false}, | |
| 222 | + | } | |
| 223 | + | for _, tt := range tests { | |
| 224 | + | t.Run(tt.name, func(t *testing.T) { | |
| 225 | + | t.Parallel() | |
| 226 | + | ts := eventsServer(t, &watchProbe{err: tt.err}, time.Hour, 0) | |
| 227 | + | resp, _ := open(t, ts, "topic=home", nil) | |
| 228 | + | assert.Equal(t, tt.want, resp.StatusCode) | |
| 229 | + | assert.Equal(t, tt.retryAfter, resp.Header.Get("Retry-After") != "") | |
| 230 | + | assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream") | |
| 231 | + | assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) | |
| 232 | + | }) | |
| 233 | + | } | |
| 234 | + | } | |
| 204 | 235 | ||
| 205 | - | f.err = connect.NewError(connect.CodeNotFound, errors.New("x")) | |
| 206 | - | resp, _ = open(t, ts, "topic=home", nil) | |
| 207 | - | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 208 | - | f.err = errors.New("boom") | |
| 209 | - | resp, _ = open(t, ts, "topic=home", nil) | |
| 236 | + | // A panic in the watch runs outside the handler goroutine, where net/http | |
| 237 | + | // would not recover it: it ends the stream with a 500 instead of the process. | |
| 238 | + | func TestEventsWatchPanicIsAnError(t *testing.T) { | |
| 239 | + | t.Parallel() | |
| 240 | + | m := mocks.NewEventService(t) | |
| 241 | + | m.EXPECT().Watch(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 242 | + | RunAndReturn(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error { | |
| 243 | + | panic("boom") | |
| 244 | + | }) | |
| 245 | + | ts := httptest.NewServer(newTestServer(t, Config{ | |
| 246 | + | Repos: reposServing(t, publicRepo(t, "x")), | |
| 247 | + | Browse: mocks.NewBrowseService(t), | |
| 248 | + | Events: m, | |
| 249 | + | })) | |
| 250 | + | t.Cleanup(ts.Close) | |
| 251 | + | resp, _ := open(t, ts, "topic=home", nil) | |
| 210 | 252 | assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) | |
| 211 | 253 | } | |
| 212 | 254 | ||
| 255 | + | // The topic of a repository the visitor cannot see is refused before any | |
| 256 | + | // watch is opened: a private repository is a missing one. | |
| 257 | + | func TestEventsPrivateRepositoryTopic(t *testing.T) { | |
| 258 | + | t.Parallel() | |
| 259 | + | f := &watchProbe{release: make(chan struct{})} | |
| 260 | + | srv := newTestServer(t, Config{ | |
| 261 | + | Repos: reposServing(t, publicRepo(t, "pub")), Browse: mocks.NewBrowseService(t), Events: f.service(t), | |
| 262 | + | }) | |
| 263 | + | ts := httptest.NewServer(srv) | |
| 264 | + | t.Cleanup(ts.Close) | |
| 265 | + | for _, query := range []string{"topic=repo:secret", "topic=repo:Bad"} { | |
| 266 | + | resp, _ := open(t, ts, query, nil) | |
| 267 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode, query) | |
| 268 | + | } | |
| 269 | + | assert.Zero(t, f.opened.Load(), "no watch was opened for them") | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // The owner's stream is the owner's: the watch is opened as the owner. | |
| 273 | + | func TestEventsOwnerWatchesAsOwner(t *testing.T) { | |
| 274 | + | t.Parallel() | |
| 275 | + | f := &watchProbe{release: make(chan struct{})} | |
| 276 | + | sess, _ := newRevocable(t, "tok") | |
| 277 | + | ts := eventsServerWith(t, f, time.Hour, 0, sess) | |
| 278 | + | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 279 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 280 | + | assert.Equal(t, domain.Owner(), f.who.Load()) | |
| 281 | + | } | |
| 282 | + | ||
| 213 | 283 | // The handler returns, and the watch it opened ends, when the visitor goes | |
| 214 | 284 | // away or the API ends the stream. | |
| 215 | 285 | func TestEventsNoLeak(t *testing.T) { | |
| 216 | - | f := &fakeEvents{release: make(chan struct{})} | |
| 286 | + | t.Parallel() | |
| 287 | + | f := &watchProbe{release: make(chan struct{})} | |
| 217 | 288 | ts := eventsServer(t, f, time.Hour, 0) | |
| 218 | 289 | ||
| 219 | 290 | resp, cancel := open(t, ts, "topic=home", nil) | |
| @@ -236,13 +307,9 @@ func TestEventsNoLeak(t *testing.T) { | |||
| 236 | 307 | require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond) | |
| 237 | 308 | } | |
| 238 | 309 | ||
| 239 | - | func TestEventsOffWithoutClient(t *testing.T) { | |
| 240 | - | srv, err := New(Config{ | |
| 241 | - | Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"), | |
| 242 | - | Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"), | |
| 243 | - | PublicURL: "https://forge.example", | |
| 244 | - | }) | |
| 245 | - | require.NoError(t, err) | |
| 310 | + | func TestEventsOffWithoutEventService(t *testing.T) { | |
| 311 | + | t.Parallel() | |
| 312 | + | srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) | |
| 246 | 313 | rec := httptest.NewRecorder() | |
| 247 | 314 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil)) | |
| 248 | 315 | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| @@ -252,18 +319,10 @@ func TestEventsOffWithoutClient(t *testing.T) { | |||
| 252 | 319 | // show it load themselves again: the summary and the refs page, whose rows | |
| 253 | 320 | // and marks depend on it. The commits page shows a branch, not the default. | |
| 254 | 321 | func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | |
| 255 | - | api := http.NewServeMux() | |
| 256 | - | api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{})) | |
| 257 | - | backend := httptest.NewServer(api) | |
| 258 | - | t.Cleanup(backend.Close) | |
| 259 | - | srv, err := New(Config{ | |
| 260 | - | Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL), | |
| 261 | - | Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL), | |
| 262 | - | PublicURL: "https://forge.example", | |
| 263 | - | }) | |
| 264 | - | require.NoError(t, err) | |
| 322 | + | t.Parallel() | |
| 323 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "x")), Browse: mocks.NewBrowseService(t)}) | |
| 265 | 324 | ||
| 266 | - | ev := &pb.Event{Kind: &pb.Event_RepoUpdated{RepoUpdated: &pb.RepoUpdated{Repo: &pb.Repo{Name: "x"}}}} | |
| 325 | + | ev := ports.WatchEvent{Event: domain.RepoUpdated{Repo: repoName(t, "x")}} | |
| 267 | 326 | for view, wantGap := range map[string]bool{"summary": true, "refs": true, "commits": false} { | |
| 268 | 327 | ls := &liveStream{s: srv, repo: "x", view: view} | |
| 269 | 328 | got, err := ls.render(t.Context(), ev) | |
| @@ -276,30 +335,28 @@ func TestRepoUpdatedReloadsSummaryAndRefs(t *testing.T) { | |||
| 276 | 335 | } | |
| 277 | 336 | } | |
| 278 | 337 | ||
| 279 | - | func limiterFor(sess SessionAuth) FailureLimiter { | |
| 338 | + | func limiterFor(sess ports.WebSessionService) ports.FailureLimiter { | |
| 280 | 339 | if sess == nil { | |
| 281 | 340 | return nil | |
| 282 | 341 | } | |
| 283 | 342 | return &countingLimiter{} | |
| 284 | 343 | } | |
| 285 | 344 | ||
| 286 | - | // revocable is a SessionAuth whose one session ends when the test says so, like | |
| 287 | - | // SignOutEverywhere does. | |
| 288 | - | type revocable struct { | |
| 289 | - | fakeSessions | |
| 290 | - | ended atomic.Bool | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | func (r *revocable) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | |
| 294 | - | return ctx, tok == r.valid && !r.ended.Load(), nil | |
| 345 | + | // newRevocable is a ports.WebSessionService whose one session ends when the test sets | |
| 346 | + | // ended, like SignOutEverywhere does. | |
| 347 | + | func newRevocable(t *testing.T, valid string) (*mocks.WebSessionService, *atomic.Bool) { | |
| 348 | + | t.Helper() | |
| 349 | + | var ended atomic.Bool | |
| 350 | + | return newVerifier(t, valid, func(tok string) (bool, error) { return tok == valid && !ended.Load(), nil }), &ended | |
| 295 | 351 | } | |
| 296 | 352 | ||
| 297 | 353 | // GO-002: the stream of the owner is checked before each event is sent, not | |
| 298 | 354 | // only on the heartbeat: after the session ended, the next event is not sent | |
| 299 | 355 | // and the stream ends. | |
| 300 | 356 | func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |
| 301 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | |
| 302 | - | sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} | |
| 357 | + | t.Parallel() | |
| 358 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} | |
| 359 | + | sess, ended := newRevocable(t, "tok") | |
| 303 | 360 | ts := eventsServerWith(t, f, time.Hour, 0, sess) // no heartbeat in the test | |
| 304 | 361 | resp, cancel := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=tok"}) | |
| 305 | 362 | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| @@ -318,7 +375,7 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||
| 318 | 375 | // The stream is up; a first event goes through while the session lives. | |
| 319 | 376 | require.Equal(t, ": connected", line()) | |
| 320 | 377 | line() | |
| 321 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | |
| 378 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} | |
| 322 | 379 | var seen []string | |
| 323 | 380 | for l := line(); l != "data: gap"; l = line() { | |
| 324 | 381 | seen = append(seen, l) | |
| @@ -327,9 +384,9 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||
| 327 | 384 | assert.Contains(t, seen, "id: b-1") | |
| 328 | 385 | ||
| 329 | 386 | // Signed out everywhere: the next event is not sent, the stream ends. | |
| 330 | - | sess.ended.Store(true) | |
| 387 | + | ended.Store(true) | |
| 331 | 388 | select { | |
| 332 | - | case f.push <- &pb.Event{Id: "b-2", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}}: | |
| 389 | + | case f.push <- ports.WatchEvent{ID: "b-2", Gap: true}: | |
| 333 | 390 | case <-time.After(5 * time.Second): | |
| 334 | 391 | t.Fatal("the event was not delivered to the web") | |
| 335 | 392 | } | |
| @@ -340,13 +397,14 @@ func TestEventsOwnerStreamEndsBeforeNextEvent(t *testing.T) { | |||
| 340 | 397 | ||
| 341 | 398 | // A visitor's stream does not depend on sessions: events keep flowing. | |
| 342 | 399 | func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | |
| 343 | - | f := &fakeEvents{release: make(chan struct{}), push: make(chan *pb.Event)} | |
| 344 | - | sess := &revocable{fakeSessions: fakeSessions{valid: "tok"}} | |
| 345 | - | sess.ended.Store(true) | |
| 400 | + | t.Parallel() | |
| 401 | + | f := &watchProbe{release: make(chan struct{}), push: make(chan ports.WatchEvent)} | |
| 402 | + | sess, ended := newRevocable(t, "tok") | |
| 403 | + | ended.Store(true) | |
| 346 | 404 | ts := eventsServerWith(t, f, time.Hour, 0, sess) | |
| 347 | 405 | resp, _ := open(t, ts, "topic=home", nil) | |
| 348 | 406 | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 349 | - | f.push <- &pb.Event{Id: "b-1", Kind: &pb.Event_Gap{Gap: &pb.Gap{}}} | |
| 407 | + | f.push <- ports.WatchEvent{ID: "b-1", Gap: true} | |
| 350 | 408 | sc := bufio.NewScanner(resp.Body) | |
| 351 | 409 | found := false | |
| 352 | 410 | for sc.Scan() { | |
| @@ -361,8 +419,9 @@ func TestEventsVisitorStreamIgnoresSessions(t *testing.T) { | |||
| 361 | 419 | // GO-006: a stream opened with an invalid cookie is anonymous, and its headers | |
| 362 | 420 | // say the answer must not be stored and varies on the cookie. | |
| 363 | 421 | func TestEventsInvalidCookieStream(t *testing.T) { | |
| 364 | - | f := &fakeEvents{release: make(chan struct{})} | |
| 365 | - | ts := eventsServerWith(t, f, time.Hour, 0, &fakeSessions{valid: "tok"}) | |
| 422 | + | t.Parallel() | |
| 423 | + | f := &watchProbe{release: make(chan struct{})} | |
| 424 | + | ts := eventsServerWith(t, f, time.Hour, 0, newSessions(t, "tok")) | |
| 366 | 425 | resp, _ := open(t, ts, "topic=home", map[string]string{"Cookie": CookieName + "=bad"}) | |
| 367 | 426 | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 368 | 427 | assert.Equal(t, "no-store, no-transform", resp.Header.Get("Cache-Control")) | |
A internal/adapters/web/fanout_test.go +213 -0
| @@ -0,0 +1,213 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "sync" | |
| 9 | + | "sync/atomic" | |
| 10 | + | "testing" | |
| 11 | + | "testing/synctest" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | func frag(s string) []fragment { return []fragment{{event: "refs-list", html: s}} } | |
| 23 | + | ||
| 24 | + | func TestFanoutBuildsOncePerKey(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | base := fanKey{repo: "x", event: "b-1"} | |
| 27 | + | for _, tt := range []struct { | |
| 28 | + | name string | |
| 29 | + | keys []fanKey | |
| 30 | + | built int32 | |
| 31 | + | }{ | |
| 32 | + | {"same key", []fanKey{base, base, base}, 1}, | |
| 33 | + | {"owner and anonymous never share", []fanKey{base, {repo: "x", event: "b-1", owner: true}}, 2}, | |
| 34 | + | {"another event", []fanKey{base, {repo: "x", event: "b-2"}}, 2}, | |
| 35 | + | {"another repository", []fanKey{base, {repo: "y", event: "b-1"}}, 2}, | |
| 36 | + | {"no event id: nothing to share by", []fanKey{{repo: "x"}, {repo: "x"}}, 2}, | |
| 37 | + | } { | |
| 38 | + | t.Run(tt.name, func(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 40 | + | var f fanout | |
| 41 | + | var built atomic.Int32 | |
| 42 | + | for _, k := range tt.keys { | |
| 43 | + | got, err := f.do(context.Background(), k, func() ([]fragment, error) { | |
| 44 | + | built.Add(1) | |
| 45 | + | return frag("for-owner=" + map[bool]string{true: "yes", false: "no"}[k.owner]), nil | |
| 46 | + | }) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | assert.Equal(t, "for-owner="+map[bool]string{true: "yes", false: "no"}[k.owner], got[0].html, | |
| 49 | + | "a watcher never gets the output built for the other class") | |
| 50 | + | } | |
| 51 | + | assert.Equal(t, tt.built, built.Load()) | |
| 52 | + | }) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // While the first watcher builds, every other one waits for it: in a synctest | |
| 57 | + | // bubble Wait returns once all goroutines are blocked, so the count of the | |
| 58 | + | // watchers that entered and none that returned proves the others wait on the | |
| 59 | + | // build instead of making their own. | |
| 60 | + | func TestFanoutConcurrentWatchersShareOneBuild(t *testing.T) { | |
| 61 | + | t.Parallel() | |
| 62 | + | const watchers = 50 | |
| 63 | + | synctest.Test(t, func(t *testing.T) { | |
| 64 | + | var f fanout | |
| 65 | + | var built, entered, returned atomic.Int32 | |
| 66 | + | release := make(chan struct{}) | |
| 67 | + | key := fanKey{repo: "x", event: "b-1"} | |
| 68 | + | var wg sync.WaitGroup | |
| 69 | + | results := make([][]fragment, watchers) | |
| 70 | + | for i := range results { | |
| 71 | + | wg.Add(1) | |
| 72 | + | go func() { | |
| 73 | + | defer wg.Done() | |
| 74 | + | entered.Add(1) | |
| 75 | + | results[i], _ = f.do(context.Background(), key, func() ([]fragment, error) { | |
| 76 | + | built.Add(1) | |
| 77 | + | <-release | |
| 78 | + | return frag("once"), nil | |
| 79 | + | }) | |
| 80 | + | returned.Add(1) | |
| 81 | + | }() | |
| 82 | + | } | |
| 83 | + | synctest.Wait() // the builder is on release, the others on its entry | |
| 84 | + | require.Equal(t, int32(watchers), entered.Load()) | |
| 85 | + | require.Equal(t, int32(1), built.Load(), "one builder") | |
| 86 | + | require.Zero(t, returned.Load(), "the other %d wait for it", watchers-1) | |
| 87 | + | ||
| 88 | + | close(release) | |
| 89 | + | wg.Wait() | |
| 90 | + | assert.Equal(t, int32(1), built.Load()) | |
| 91 | + | for _, r := range results { | |
| 92 | + | assert.Equal(t, frag("once"), r) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestFanoutFailureIsNotKept(t *testing.T) { | |
| 98 | + | t.Parallel() | |
| 99 | + | var f fanout | |
| 100 | + | key := fanKey{repo: "x", event: "b-1"} | |
| 101 | + | _, err := f.do(context.Background(), key, func() ([]fragment, error) { return nil, errors.New("boom") }) | |
| 102 | + | require.Error(t, err) | |
| 103 | + | got, err := f.do(context.Background(), key, func() ([]fragment, error) { return frag("ok"), nil }) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | assert.Equal(t, frag("ok"), got, "the next watcher builds again") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestFanoutWaiterStopsWithItsContext(t *testing.T) { | |
| 109 | + | t.Parallel() | |
| 110 | + | var f fanout | |
| 111 | + | key := fanKey{repo: "x", event: "b-1"} | |
| 112 | + | started, release := make(chan struct{}), make(chan struct{}) | |
| 113 | + | go func() { | |
| 114 | + | _, _ = f.do(context.Background(), key, func() ([]fragment, error) { | |
| 115 | + | close(started) | |
| 116 | + | <-release | |
| 117 | + | return frag("late"), nil | |
| 118 | + | }) | |
| 119 | + | }() | |
| 120 | + | <-started | |
| 121 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 122 | + | cancel() | |
| 123 | + | _, err := f.do(ctx, key, func() ([]fragment, error) { return frag("never"), nil }) | |
| 124 | + | assert.ErrorIs(t, err, context.Canceled) | |
| 125 | + | close(release) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestFanoutIsBounded(t *testing.T) { | |
| 129 | + | t.Parallel() | |
| 130 | + | var f fanout | |
| 131 | + | for i := range fanMax + 10 { | |
| 132 | + | key := fanKey{repo: "x", event: "b-" + string(rune('A'+i%26)) + string(rune('a'+i/26))} | |
| 133 | + | _, err := f.do(context.Background(), key, func() ([]fragment, error) { return frag("x"), nil }) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | } | |
| 136 | + | assert.LessOrEqual(t, len(f.m), fanMax) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // refsMocks is a web server over mocks that expect the refs fragment to load | |
| 140 | + | // the repository and its refs once per build of each class of watcher | |
| 141 | + | // (anonymous, owner), asking the use cases as that class's principal, and to | |
| 142 | + | // ask for a commit once per build when commits is set. | |
| 143 | + | func refsMocks(t *testing.T, anonymous, owner int, commits bool) *Server { | |
| 144 | + | t.Helper() | |
| 145 | + | const id = "0123456789abcdef0123456789abcdef01234567" | |
| 146 | + | repos := mocks.NewRepoService(t) | |
| 147 | + | browse := mocks.NewBrowseService(t) | |
| 148 | + | for _, class := range []struct { | |
| 149 | + | p domain.Principal | |
| 150 | + | builds int | |
| 151 | + | }{{domain.Anonymous(), anonymous}, {domain.Owner(), owner}} { | |
| 152 | + | p, builds := class.p, class.builds | |
| 153 | + | if builds == 0 { | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | repos.EXPECT().Get(mock.Anything, p, repoName(t, "x")).Return(publicRepo(t, "x").Repo, nil).Times(builds) | |
| 157 | + | browse.EXPECT().ListRefs(mock.Anything, p, repoName(t, "x"), mock.MatchedBy(func(params ports.ListRefsParams) bool { | |
| 158 | + | return params.PageSize == maxRefs // the refs are asked in one page | |
| 159 | + | })).Return(ports.RefPage{Refs: []domain.Ref{ | |
| 160 | + | {Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, CommitID: objectID(t, id)}, | |
| 161 | + | {Name: "refs/tags/v1", ShortName: "v1", Kind: domain.RefKindTag, CommitID: objectID(t, id)}, | |
| 162 | + | }}, nil).Times(builds) | |
| 163 | + | if commits { | |
| 164 | + | browse.EXPECT().ListCommits(mock.Anything, p, repoName(t, "x"), mock.Anything).RunAndReturn( | |
| 165 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) { | |
| 166 | + | return ports.HistoryPage{Commits: []domain.Commit{{ID: objectID(t, params.Revision.String()), Summary: "subject"}}}, nil | |
| 167 | + | }).Times(builds) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | repos.EXPECT().View(mock.Anything, mock.Anything).Return(publicRepo(t, "x"), nil).Times(anonymous + owner) | |
| 171 | + | return newTestServer(t, Config{Repos: repos, Browse: browse}) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // A push is rendered once for the watchers of the same class, and the owner | |
| 175 | + | // and the anonymous visitors never share what was built. | |
| 176 | + | func TestLiveRefsSharesAPushBetweenWatchers(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | owner := domain.ContextWithPrincipal(context.WithValue(context.Background(), signedInKey{}, struct{}{}), domain.Owner()) | |
| 179 | + | anon := context.Background() | |
| 180 | + | ||
| 181 | + | for _, tt := range []struct { | |
| 182 | + | name string | |
| 183 | + | ctxs []context.Context | |
| 184 | + | anonymous, mine int // builds: one per class | |
| 185 | + | }{ | |
| 186 | + | {"five anonymous watchers", []context.Context{anon, anon, anon, anon, anon}, 1, 0}, | |
| 187 | + | {"owner and anonymous", []context.Context{anon, owner, anon, owner}, 1, 1}, | |
| 188 | + | } { | |
| 189 | + | t.Run(tt.name, func(t *testing.T) { | |
| 190 | + | t.Parallel() | |
| 191 | + | s := refsMocks(t, tt.anonymous, tt.mine, true) | |
| 192 | + | ev := ports.WatchEvent{ID: "b-1", Event: domain.PushEvent{Repo: repoName(t, "x")}} | |
| 193 | + | for _, ctx := range tt.ctxs { | |
| 194 | + | st := &liveStream{s: s, repo: "x", view: "refs"} | |
| 195 | + | frags, err := st.render(ctx, ev) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | require.Len(t, frags, 2) | |
| 198 | + | assert.Equal(t, "refs-list", frags[0].event) | |
| 199 | + | assert.Contains(t, frags[0].html, `id="ref-rows"`) | |
| 200 | + | } | |
| 201 | + | }) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // The refs are asked in one page of up to maxRefs (the mock only answers that). | |
| 206 | + | func TestLoadRepoAsksTheRefsInOnePage(t *testing.T) { | |
| 207 | + | t.Parallel() | |
| 208 | + | s := refsMocks(t, 1, 0, false) | |
| 209 | + | d, err := s.loadRepo(context.Background(), "x") | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | assert.Len(t, d.branches, 1) | |
| 212 | + | assert.Len(t, d.tags, 1) | |
| 213 | + | } | |
M internal/adapters/web/fragments.go +206 -71
| @@ -5,12 +5,15 @@ package web | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "errors" | |
| 8 | 9 | "strconv" | |
| 10 | + | "sync" | |
| 11 | + | "time" | |
| 9 | 12 | ||
| 10 | - | "connectrpc.com/connect" | |
| 11 | 13 | "github.com/a-h/templ" | |
| 12 | 14 | ||
| 13 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | 19 | // liveStream turns the events of Watch into the fragments one page shows. | |
| @@ -34,48 +37,73 @@ func renderFragment(ctx context.Context, event string, c templ.Component) (fragm | |||
| 34 | 37 | ||
| 35 | 38 | var gap = []fragment{{event: "gap", html: "gap"}} | |
| 36 | 39 | ||
| 40 | + | // liveEvent reports whether the event of a watch is one the pages follow: a | |
| 41 | + | // gap, or one of the events of the forge. The use case may send others, which | |
| 42 | + | // no page shows and no stream carries, not even their id. | |
| 43 | + | func liveEvent(we ports.WatchEvent) bool { | |
| 44 | + | if we.Gap { | |
| 45 | + | return true | |
| 46 | + | } | |
| 47 | + | switch we.Event.(type) { | |
| 48 | + | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 49 | + | domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded: | |
| 50 | + | return true | |
| 51 | + | } | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | ||
| 37 | 55 | // render returns the fragments for an event, none when it changes nothing on | |
| 38 | 56 | // the page. | |
| 39 | - | func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 40 | - | if ev == nil { | |
| 41 | - | return nil, nil | |
| 42 | - | } | |
| 43 | - | if ev.GetGap() != nil { | |
| 57 | + | func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 58 | + | if we.Gap { | |
| 44 | 59 | return gap, nil | |
| 45 | 60 | } | |
| 61 | + | if we.Event == nil { | |
| 62 | + | return nil, nil | |
| 63 | + | } | |
| 46 | 64 | if l.repo == "" { | |
| 47 | - | return l.home(ctx, ev) | |
| 65 | + | return l.home(ctx, we) | |
| 66 | + | } | |
| 67 | + | return l.repoPage(ctx, we) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // repoByName reads a repository as the caller of the request. A name that is | |
| 71 | + | // not valid is a repository that is not there. | |
| 72 | + | func (l *liveStream) repoByName(ctx context.Context, rawName string) (ports.RepoView, error) { | |
| 73 | + | name, err := domain.ParseRepoName(rawName) | |
| 74 | + | if err != nil { | |
| 75 | + | return ports.RepoView{}, domain.ErrNotFound | |
| 48 | 76 | } | |
| 49 | - | return l.repoPage(ctx, ev) | |
| 77 | + | return l.s.repoView(ctx, name) | |
| 50 | 78 | } | |
| 51 | 79 | ||
| 52 | 80 | // home upserts the row of the repository an event is about. The row is read | |
| 53 | - | // again from the API as the visitor of the stream, so a repository that was | |
| 54 | - | // deleted or that the visitor can no longer see (public to private) removes | |
| 55 | - | // the row, and the stream never carries more than the list shows. | |
| 56 | - | func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 81 | + | // again from the use case as the principal of the stream, so a repository | |
| 82 | + | // that was deleted or that the visitor can no longer see (public to private) | |
| 83 | + | // removes the row, and the stream never carries more than the list shows. | |
| 84 | + | func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 57 | 85 | var name string | |
| 58 | 86 | fresh := false | |
| 59 | - | switch k := ev.GetKind().(type) { | |
| 60 | - | case *pb.Event_RepoDeleted: | |
| 61 | - | return l.gone(ctx, k.RepoDeleted.GetRepo()) | |
| 62 | - | case *pb.Event_RepoCreated: | |
| 63 | - | name = k.RepoCreated.GetRepo().GetName() | |
| 64 | - | case *pb.Event_RepoUpdated: | |
| 65 | - | name = k.RepoUpdated.GetRepo().GetName() | |
| 66 | - | case *pb.Event_Pushed: | |
| 67 | - | name, fresh = k.Pushed.GetRepo(), true | |
| 87 | + | switch ev := we.Event.(type) { | |
| 88 | + | case domain.RepoDeleted: | |
| 89 | + | return l.gone(ctx, ev.Repo.String()) | |
| 90 | + | case domain.RepoCreated: | |
| 91 | + | name = ev.Repo.String() | |
| 92 | + | case domain.RepoUpdated: | |
| 93 | + | name = ev.Repo.String() | |
| 94 | + | case domain.PushEvent: | |
| 95 | + | name, fresh = ev.Repo.String(), true | |
| 68 | 96 | default: | |
| 69 | 97 | return nil, nil | |
| 70 | 98 | } | |
| 71 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |
| 99 | + | view, err := l.repoByName(ctx, name) | |
| 72 | 100 | switch { | |
| 73 | - | case connect.CodeOf(err) == connect.CodeNotFound: | |
| 101 | + | case errors.Is(err, domain.ErrNotFound): | |
| 74 | 102 | return l.gone(ctx, name) | |
| 75 | 103 | case err != nil: | |
| 76 | 104 | return nil, err | |
| 77 | 105 | } | |
| 78 | - | row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false) | |
| 106 | + | row := homeRow(view, l.s.cfg.Now(), false) | |
| 79 | 107 | row.Fresh = fresh | |
| 80 | 108 | f, err := renderFragment(ctx, "home-list", HomeRowView(row)) | |
| 81 | 109 | return []fragment{f}, err | |
| @@ -87,16 +115,16 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) | |||
| 87 | 115 | } | |
| 88 | 116 | ||
| 89 | 117 | // repoPage handles the events of the topic of one repository. | |
| 90 | - | func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) { | |
| 91 | - | switch k := ev.GetKind().(type) { | |
| 92 | - | case *pb.Event_RepoDeleted, *pb.Event_RepoCreated: | |
| 118 | + | func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) { | |
| 119 | + | switch ev := we.Event.(type) { | |
| 120 | + | case domain.RepoDeleted, domain.RepoCreated: | |
| 93 | 121 | // Gone, or a new repository under the same name: whatever the page | |
| 94 | 122 | // shows is not true any more. | |
| 95 | 123 | return gap, nil | |
| 96 | - | case *pb.Event_RepoUpdated: | |
| 97 | - | _, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |
| 124 | + | case domain.RepoUpdated: | |
| 125 | + | _, err := l.repoByName(ctx, l.repo) | |
| 98 | 126 | switch { | |
| 99 | - | case connect.CodeOf(err) == connect.CodeNotFound: | |
| 127 | + | case errors.Is(err, domain.ErrNotFound): | |
| 100 | 128 | return gap, nil // no longer visible | |
| 101 | 129 | case err != nil: | |
| 102 | 130 | return nil, err | |
| @@ -104,26 +132,26 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||
| 104 | 132 | return gap, nil // the description, the default branch | |
| 105 | 133 | } | |
| 106 | 134 | return nil, nil | |
| 107 | - | case *pb.Event_MergeRequestOpened: | |
| 108 | - | return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload") | |
| 109 | - | case *pb.Event_MergeRequestMerged: | |
| 110 | - | return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload") | |
| 111 | - | case *pb.Event_MergeRequestClosed: | |
| 112 | - | return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload") | |
| 113 | - | case *pb.Event_CommentAdded: | |
| 114 | - | return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload") | |
| 115 | - | case *pb.Event_Pushed: | |
| 135 | + | case domain.MergeRequestOpened: | |
| 136 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload") | |
| 137 | + | case domain.MergeRequestMerged: | |
| 138 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload") | |
| 139 | + | case domain.MergeRequestClosed: | |
| 140 | + | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload") | |
| 141 | + | case domain.CommentAdded: | |
| 142 | + | return l.mrNotice(ctx, ev.Number, "a comment was added — reload") | |
| 143 | + | case domain.PushEvent: | |
| 116 | 144 | switch l.view { | |
| 117 | 145 | case "mr": | |
| 118 | - | return l.mrPushed(ctx, k.Pushed) | |
| 146 | + | return l.mrPushed(ctx, ev) | |
| 119 | 147 | case "mrs": | |
| 120 | 148 | return nil, nil | |
| 121 | 149 | } | |
| 122 | 150 | switch l.view { | |
| 123 | 151 | case "refs": | |
| 124 | - | return l.refs(ctx) | |
| 152 | + | return l.refs(ctx, we.ID) | |
| 125 | 153 | case "commits": | |
| 126 | - | return l.banner(ctx, k.Pushed) | |
| 154 | + | return l.banner(ctx, ev) | |
| 127 | 155 | } | |
| 128 | 156 | return l.lastPush(ctx) | |
| 129 | 157 | } | |
| @@ -132,39 +160,144 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er | |||
| 132 | 160 | ||
| 133 | 161 | // lastPush is the "last push" fact of the summary. | |
| 134 | 162 | func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) { | |
| 135 | - | res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo})) | |
| 163 | + | view, err := l.repoByName(ctx, l.repo) | |
| 136 | 164 | if err != nil { | |
| 137 | 165 | return nil, err | |
| 138 | 166 | } | |
| 139 | - | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now()))) | |
| 167 | + | f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(view, l.s.cfg.Now()))) | |
| 140 | 168 | return []fragment{f}, err | |
| 141 | 169 | } | |
| 142 | 170 | ||
| 143 | - | // refs is the table of the refs page and the facts of its rail. | |
| 144 | - | func (l *liveStream) refs(ctx context.Context) ([]fragment, error) { | |
| 145 | - | d, err := l.s.loadRepo(ctx, l.repo) | |
| 146 | - | if err != nil { | |
| 147 | - | return nil, err | |
| 171 | + | // refs is the table of the refs page and the facts of its rail. The fragments | |
| 172 | + | // of one event are built once for all the watchers of the repository that | |
| 173 | + | // see the same things (see fanout); eventID names the event. | |
| 174 | + | func (l *liveStream) refs(ctx context.Context, eventID string) ([]fragment, error) { | |
| 175 | + | key := fanKey{repo: l.repo, event: eventID, owner: SignedIn(ctx)} | |
| 176 | + | return l.s.refsFan.do(ctx, key, func() ([]fragment, error) { | |
| 177 | + | d, err := l.s.loadRepo(ctx, l.repo) | |
| 178 | + | if err != nil { | |
| 179 | + | return nil, err | |
| 180 | + | } | |
| 181 | + | p := l.s.refsPage(ctx, d) | |
| 182 | + | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | |
| 183 | + | if err != nil { | |
| 184 | + | return nil, err | |
| 185 | + | } | |
| 186 | + | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | |
| 187 | + | return []fragment{body, kv}, err | |
| 188 | + | }) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | const ( | |
| 192 | + | // fanTTL is how long the fragments of an event are kept for the watchers | |
| 193 | + | // that are told of it a little later than the first. | |
| 194 | + | fanTTL = 2 * time.Second | |
| 195 | + | // fanMax bounds the events kept; past it an event is built for each | |
| 196 | + | // watcher, as without sharing. | |
| 197 | + | fanMax = 64 | |
| 198 | + | ) | |
| 199 | + | ||
| 200 | + | // fanKey says which watchers may share the fragments of an event: those of | |
| 201 | + | // the same repository, for the same event, and of the same class. The class | |
| 202 | + | // is anonymous or owner (a session is the owner's alone, ADR 0018): what is | |
| 203 | + | // built as the owner can show what an anonymous visitor may not see, so the | |
| 204 | + | // two never share an entry. | |
| 205 | + | type fanKey struct { | |
| 206 | + | repo, event string | |
| 207 | + | owner bool | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | type fanEntry struct { | |
| 211 | + | done chan struct{} // closed once frags or err is set | |
| 212 | + | frags []fragment | |
| 213 | + | err error | |
| 214 | + | at time.Time // when it was built; zero while it is | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // fanout builds the fragments of an event once for the many watchers that | |
| 218 | + | // receive it: a push to a repository that 256 pages watch would otherwise | |
| 219 | + | // load the refs and the commits they designate 256 times. The zero value is | |
| 220 | + | // ready. A failure is never kept: the next watcher builds it again. | |
| 221 | + | type fanout struct { | |
| 222 | + | mu sync.Mutex | |
| 223 | + | m map[fanKey]*fanEntry | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // do returns the fragments of key, built by build when nobody has or is | |
| 227 | + | // building them. build runs with the context of the watcher that calls it, | |
| 228 | + | // which is of the class of the key; a watcher whose builder failed (its | |
| 229 | + | // request was cancelled, say) does not fail the others, they build again. | |
| 230 | + | func (f *fanout) do(ctx context.Context, key fanKey, build func() ([]fragment, error)) ([]fragment, error) { | |
| 231 | + | if key.event == "" { | |
| 232 | + | return build() // no event to share by | |
| 148 | 233 | } | |
| 149 | - | p := l.s.refsPage(ctx, d) | |
| 150 | - | body, err := renderFragment(ctx, "refs-list", RefsBody(p)) | |
| 151 | - | if err != nil { | |
| 152 | - | return nil, err | |
| 234 | + | for range 3 { | |
| 235 | + | f.mu.Lock() | |
| 236 | + | now := time.Now() | |
| 237 | + | for k, e := range f.m { | |
| 238 | + | if !e.at.IsZero() && now.Sub(e.at) > fanTTL { | |
| 239 | + | delete(f.m, k) | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | e, shared := f.m[key] | |
| 243 | + | if !shared { | |
| 244 | + | if len(f.m) >= fanMax { | |
| 245 | + | f.mu.Unlock() | |
| 246 | + | return build() | |
| 247 | + | } | |
| 248 | + | if f.m == nil { | |
| 249 | + | f.m = make(map[fanKey]*fanEntry) | |
| 250 | + | } | |
| 251 | + | e = &fanEntry{done: make(chan struct{})} | |
| 252 | + | f.m[key] = e | |
| 253 | + | } | |
| 254 | + | f.mu.Unlock() | |
| 255 | + | ||
| 256 | + | if !shared { | |
| 257 | + | return f.lead(key, e, build) | |
| 258 | + | } | |
| 259 | + | select { | |
| 260 | + | case <-e.done: | |
| 261 | + | case <-ctx.Done(): | |
| 262 | + | return nil, ctx.Err() | |
| 263 | + | } | |
| 264 | + | if e.err == nil { | |
| 265 | + | return e.frags, nil | |
| 266 | + | } | |
| 153 | 267 | } | |
| 154 | - | kv, err := renderFragment(ctx, "refs-list", RefsKV(p)) | |
| 155 | - | return []fragment{body, kv}, err | |
| 268 | + | return build() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // lead builds the entry that the caller registered. | |
| 272 | + | func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) (frags []fragment, err error) { | |
| 273 | + | err = errors.New("web: fragments were not built") // a panic of build | |
| 274 | + | defer func() { | |
| 275 | + | f.mu.Lock() | |
| 276 | + | e.frags, e.err, e.at = frags, err, time.Now() | |
| 277 | + | if err != nil && f.m[key] == e { | |
| 278 | + | delete(f.m, key) | |
| 279 | + | } | |
| 280 | + | f.mu.Unlock() | |
| 281 | + | close(e.done) | |
| 282 | + | }() | |
| 283 | + | return build() | |
| 156 | 284 | } | |
| 157 | 285 | ||
| 158 | 286 | // banner tells the commits page how many commits its branch got since the | |
| 159 | 287 | // head it lists. | |
| 160 | - | func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | |
| 288 | + | // | |
| 289 | + | //hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only | |
| 290 | + | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 161 | 291 | if l.ref == "" || !isFullID(l.head) { | |
| 162 | 292 | return nil, nil | |
| 163 | 293 | } | |
| 164 | 294 | tip, touched := "", false | |
| 165 | - | for _, u := range p.GetRefUpdates() { | |
| 166 | - | if u.GetRef() == "refs/heads/"+l.ref { | |
| 167 | - | tip, touched = u.GetNewId(), true | |
| 295 | + | for _, u := range p.Updates { | |
| 296 | + | if u.Ref == "refs/heads/"+l.ref { | |
| 297 | + | tip, touched = u.New, true | |
| 298 | + | if u.IsDelete() { | |
| 299 | + | tip = "" | |
| 300 | + | } | |
| 168 | 301 | } | |
| 169 | 302 | } | |
| 170 | 303 | switch { | |
| @@ -175,9 +308,11 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||
| 175 | 308 | } | |
| 176 | 309 | text := "" | |
| 177 | 310 | if tip != l.head { | |
| 178 | - | res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 179 | - | Repo: l.repo, Revision: tip, PageSize: pageSize, | |
| 180 | - | })) | |
| 311 | + | name, err := domain.ParseRepoName(l.repo) | |
| 312 | + | if err != nil { | |
| 313 | + | return nil, err | |
| 314 | + | } | |
| 315 | + | res, err := l.s.commitList(ctx, name, tip, "", pageSize, "") | |
| 181 | 316 | if err != nil { | |
| 182 | 317 | return nil, err | |
| 183 | 318 | } | |
| @@ -185,10 +320,10 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||
| 185 | 320 | // ordered by date, not as "head..tip", so commits that are not new may | |
| 186 | 321 | // be counted before the head is met. And one page of the log being | |
| 187 | 322 | // all that is read, "100+" cannot be told from a rewritten branch | |
| 188 | - | // whose old head is further back: the API has no "is ancestor". | |
| 323 | + | // whose old head is further back: the use cases have no "is ancestor". | |
| 189 | 324 | n, found := 0, false | |
| 190 | - | for _, c := range res.Msg.GetCommits() { | |
| 191 | - | if c.GetId() == l.head { | |
| 325 | + | for _, c := range res.Commits { | |
| 326 | + | if c.ID.String() == l.head { | |
| 192 | 327 | found = true | |
| 193 | 328 | break | |
| 194 | 329 | } | |
| @@ -197,13 +332,13 @@ func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, erro | |||
| 197 | 332 | switch { | |
| 198 | 333 | case found: | |
| 199 | 334 | text = newCommits(strconv.Itoa(n), n) | |
| 200 | - | case res.Msg.GetNextPageToken() != "": | |
| 335 | + | case res.NextPageToken != "": | |
| 201 | 336 | text = newCommits(strconv.Itoa(n)+"+", 2) | |
| 202 | 337 | default: | |
| 203 | 338 | text = "the branch was rewritten — reload" | |
| 204 | 339 | } | |
| 205 | 340 | } | |
| 206 | - | c := CommitsSlot() | |
| 341 | + | c := LiveSlot("commits-head") | |
| 207 | 342 | if text != "" { | |
| 208 | 343 | c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref)) | |
| 209 | 344 | } | |
| @@ -240,12 +375,12 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([ | |||
| 240 | 375 | } | |
| 241 | 376 | ||
| 242 | 377 | // mrPushed announces a push to the source branch of the merge request shown. | |
| 243 | - | func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) { | |
| 378 | + | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 244 | 379 | if l.ref == "" { | |
| 245 | 380 | return nil, nil | |
| 246 | 381 | } | |
| 247 | - | for _, u := range p.GetRefUpdates() { | |
| 248 | - | if u.GetRef() == "refs/heads/"+l.ref { | |
| 382 | + | for _, u := range p.Updates { | |
| 383 | + | if u.Ref == "refs/heads/"+l.ref { | |
| 249 | 384 | n, err := strconv.ParseInt(l.n, 10, 64) | |
| 250 | 385 | if err != nil { | |
| 251 | 386 | return nil, nil | |
M internal/adapters/web/fragments.templ +22 -11
| @@ -55,31 +55,40 @@ templ RefsKV(p RefsPage) { | |||
| 55 | 55 | </dl> | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | // CommitsSlot is the place of the banner of the commits page, empty. | |
| 59 | - | templ CommitsSlot() { | |
| 60 | - | <div id="commits-head"></div> | |
| 58 | + | // LiveSlot is the place of a live banner, empty: a status region that stays | |
| 59 | + | // in the page. The live updates (ermiteLive in ermite.js) replace its children, | |
| 60 | + | // never the element, so that a screen reader announces the new banner (a | |
| 61 | + | // region that is inserted with its content is not announced). | |
| 62 | + | templ LiveSlot(id string) { | |
| 63 | + | <div id={ id } class="live-slot" role="status" data-slot></div> | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // liveBanner is the slot with its banner: a link that reloads or opens the | |
| 67 | + | // page. Only the children of the element reach the page that has the slot. | |
| 68 | + | templ liveBanner(id, text, href string) { | |
| 69 | + | <div id={ id } class="live-slot" role="status" data-slot><a href={ templ.SafeURL(href) }>{ text }</a></div> | |
| 61 | 70 | } | |
| 62 | 71 | ||
| 63 | 72 | // CommitsBanner announces new commits on the branch the page lists. It links | |
| 64 | 73 | // to the first page of the branch. | |
| 65 | 74 | templ CommitsBanner(text, href string) { | |
| 66 | - | <div id="commits-head" class="live-banner" role="status"> | |
| 67 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |
| 68 | - | </div> | |
| 75 | + | @liveBanner("commits-head", text, href) | |
| 69 | 76 | } | |
| 70 | 77 | ||
| 71 | - | // MRNotice tells a merge request page that it is out of date. The empty slot | |
| 72 | - | // of the page has the same id, so the first notice replaces it. | |
| 78 | + | // MRNotice tells a merge request page that it is out of date, or the list that | |
| 79 | + | // its content changed. | |
| 73 | 80 | templ MRNotice(text, href string) { | |
| 74 | - | <div id="mr-notice" class="live-banner" role="status"> | |
| 75 | - | <a href={ templ.SafeURL(href) }>{ text }</a> | |
| 76 | - | </div> | |
| 81 | + | @liveBanner("mr-notice", text, href) | |
| 77 | 82 | } | |
| 78 | 83 | ||
| 79 | 84 | // Live is the connection to the event stream, in the hint badge. It shows | |
| 80 | 85 | // only once a connection was lost: a page that never connected, or that is | |
| 81 | 86 | // live, says nothing. The events of the stream are fragments that ermiteLive | |
| 82 | 87 | // (ermite.js) swaps in the page. A page without a stream renders nothing. | |
| 88 | + | // | |
| 89 | + | // hx-sse retries a lost connection for ever, with a growing delay. A refusal | |
| 90 | + | // that will not change (404: the repository is gone or hidden; 401) is handled | |
| 91 | + | // by ermiteLive.error, which has the page fetch itself again. | |
| 83 | 92 | templ Live(l Layout) { | |
| 84 | 93 | if l.Live && l.LiveURL != "" { | |
| 85 | 94 | <span | |
| @@ -90,6 +99,8 @@ templ Live(l Layout) { | |||
| 90 | 99 | :text="data.state === 'offline' ? 'live updates paused' : ''" | |
| 91 | 100 | hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 92 | 101 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 102 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 103 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 93 | 104 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 94 | 105 | gap -> ermiteLive.reload()" | |
| 95 | 106 | ></span> | |
M internal/adapters/web/fragments_templ.go +94 -56 generated file: collapsed
A internal/adapters/web/fragments_test.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/a-h/templ" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func renderToString(t *testing.T, c templ.Component) string { | |
| 16 | + | t.Helper() | |
| 17 | + | var b bytes.Buffer | |
| 18 | + | require.NoError(t, c.Render(t.Context(), &b)) | |
| 19 | + | return b.String() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // A live banner is the content of a status region that stays in the page: the | |
| 23 | + | // empty slot of the page and the fragment of the stream are the same element | |
| 24 | + | // (same id, role and marker), so that ermiteLive can replace the children and | |
| 25 | + | // leave the region where a screen reader watches it. | |
| 26 | + | func TestLiveSlotsAreStableStatusRegions(t *testing.T) { | |
| 27 | + | t.Parallel() | |
| 28 | + | for _, tt := range []struct { | |
| 29 | + | name string | |
| 30 | + | c templ.Component | |
| 31 | + | id string | |
| 32 | + | text string | |
| 33 | + | }{ | |
| 34 | + | {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""}, | |
| 35 | + | {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""}, | |
| 36 | + | {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"}, | |
| 37 | + | {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"}, | |
| 38 | + | } { | |
| 39 | + | t.Run(tt.name, func(t *testing.T) { | |
| 40 | + | t.Parallel() | |
| 41 | + | got := renderToString(t, tt.c) | |
| 42 | + | assert.True(t, strings.HasPrefix(got, `<div id="`+tt.id+`" class="live-slot" role="status" data-slot>`), got) | |
| 43 | + | if tt.text == "" { | |
| 44 | + | assert.True(t, strings.HasSuffix(got, "data-slot></div>"), "an empty slot has no child: %s", got) | |
| 45 | + | } else { | |
| 46 | + | assert.Contains(t, got, tt.text) | |
| 47 | + | } | |
| 48 | + | assert.True(t, strings.HasSuffix(got, "</div>"), got) | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // The header's refs button is a link to the refs page, which the script turns | |
| 54 | + | // into the dialog; the filters and the buttons that need scripts are marked. | |
| 55 | + | func TestShellDegradesWithoutScripts(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 57 | + | l := Layout{Repo: "demo", Ref: "main", Short: "abc1234", Assets: Assets{}} | |
| 58 | + | got := renderToString(t, Header(l)) | |
| 59 | + | assert.Contains(t, got, `<a class="btn ref" href="/demo/refs" data-fire="refs"`) | |
| 60 | + | assert.NotContains(t, got, "hx-on:click", "no handler is inlined in the header") | |
| 61 | + | for _, want := range []string{`class="btn search" data-js`, `class="btn icon" data-js data-fire="help"`} { | |
| 62 | + | assert.Contains(t, got, want) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Data reaches the scripts through data-* attributes, never spliced into an | |
| 67 | + | // attribute that is evaluated. | |
| 68 | + | func TestDiffControlsPassDataThroughAttributes(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 70 | + | files := renderToString(t, FileList([]DiffFile{{ID: "f1'x", Path: "a.go", Status: "M"}})) | |
| 71 | + | assert.NotContains(t, files, ":aria-current", "the file in view is marked by ermite.js") | |
| 72 | + | assert.NotContains(t, files, "f1'x'", "no id is spliced into a script") | |
| 73 | + | ||
| 74 | + | link := renderToString(t, viewLink(DiffView{Both: true, View: "split"}, "split", "Split", "d", "?view=split")) | |
| 75 | + | assert.Contains(t, link, `data-fire="split"`) | |
| 76 | + | assert.Contains(t, link, `this.dataset.view`) | |
| 77 | + | assert.NotContains(t, link, "=== 'split'") | |
| 78 | + | } | |
A internal/adapters/web/helpers_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | "bnema.dev/ermite/internal/ports" | |
| 14 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // testPublicURL and testSourceURL are the instance of the servers the tests | |
| 18 | + | // build. | |
| 19 | + | const ( | |
| 20 | + | testPublicURL = "https://forge.example" | |
| 21 | + | testSourceURL = "https://forge.example/ermite" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // repoName parses a repository name the test knows is valid. | |
| 25 | + | func repoName(t testing.TB, s string) domain.RepoName { | |
| 26 | + | t.Helper() | |
| 27 | + | n, err := domain.ParseRepoName(s) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | return n | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // objectID parses a commit id the test knows is valid. | |
| 33 | + | func objectID(t testing.TB, s string) domain.ObjectID { | |
| 34 | + | t.Helper() | |
| 35 | + | id, err := domain.ParseObjectID(s) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | return id | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // publicRepo is the repository the use cases of the tests answer with: | |
| 41 | + | // public, with main as its default branch. | |
| 42 | + | func publicRepo(t testing.TB, name string) ports.RepoView { | |
| 43 | + | t.Helper() | |
| 44 | + | return ports.RepoView{ | |
| 45 | + | Repo: domain.Repo{Name: repoName(t, name), Visibility: domain.VisibilityPublic, Settings: domain.DefaultRepoSettings()}, | |
| 46 | + | DefaultBranch: "main", | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // reposFailing is a RepoService whose reads fail with err, whatever they ask. | |
| 51 | + | func reposFailing(t testing.TB, err error) *mocks.RepoService { | |
| 52 | + | t.Helper() | |
| 53 | + | m := mocks.NewRepoService(t) | |
| 54 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, err).Maybe() | |
| 55 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, err).Maybe() | |
| 56 | + | return m | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // reposServing is a RepoService that knows the repositories, by name, as seen | |
| 60 | + | // by every caller. | |
| 61 | + | func reposServing(t testing.TB, views ...ports.RepoView) *mocks.RepoService { | |
| 62 | + | t.Helper() | |
| 63 | + | m := mocks.NewRepoService(t) | |
| 64 | + | byName := map[string]ports.RepoView{} | |
| 65 | + | for _, v := range views { | |
| 66 | + | byName[v.Name.String()] = v | |
| 67 | + | } | |
| 68 | + | m.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 69 | + | func(_ context.Context, _ domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 70 | + | v, ok := byName[name.String()] | |
| 71 | + | if !ok { | |
| 72 | + | return domain.Repo{}, domain.ErrNotFound | |
| 73 | + | } | |
| 74 | + | return v.Repo, nil | |
| 75 | + | }).Maybe() | |
| 76 | + | m.EXPECT().View(mock.Anything, mock.Anything).RunAndReturn( | |
| 77 | + | func(_ context.Context, r domain.Repo) (ports.RepoView, error) { | |
| 78 | + | v, ok := byName[r.Name.String()] | |
| 79 | + | if !ok { | |
| 80 | + | return ports.RepoView{}, domain.ErrNotFound | |
| 81 | + | } | |
| 82 | + | return v, nil | |
| 83 | + | }).Maybe() | |
| 84 | + | m.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |
| 85 | + | func(context.Context, domain.Principal, int, string) (ports.RepoPage, error) { | |
| 86 | + | var page ports.RepoPage | |
| 87 | + | for _, v := range views { | |
| 88 | + | page.Repos = append(page.Repos, v.Repo) | |
| 89 | + | } | |
| 90 | + | return page, nil | |
| 91 | + | }).Maybe() | |
| 92 | + | return m | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // newTestServer builds a Server from cfg, with the instance filled in. | |
| 96 | + | func newTestServer(t testing.TB, cfg Config) *Server { | |
| 97 | + | t.Helper() | |
| 98 | + | if cfg.PublicURL == "" { | |
| 99 | + | cfg.PublicURL = testPublicURL | |
| 100 | + | } | |
| 101 | + | if cfg.SourceURL == "" { | |
| 102 | + | cfg.SourceURL = testSourceURL | |
| 103 | + | } | |
| 104 | + | if cfg.Version == "" { | |
| 105 | + | cfg.Version = "v1" | |
| 106 | + | } | |
| 107 | + | srv, err := New(cfg) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | return srv | |
| 110 | + | } | |
M internal/adapters/web/highlight.go +24 -6
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "html" | |
| @@ -109,7 +110,7 @@ const maxViewLines = 20000 | |||
| 109 | 110 | ||
| 110 | 111 | // countLines is the number of lines of src, as the code view numbers them. | |
| 111 | 112 | func countLines(src []byte) int { | |
| 112 | - | n := strings.Count(string(src), "\n") | |
| 113 | + | n := bytes.Count(src, []byte{'\n'}) | |
| 113 | 114 | if len(src) > 0 && src[len(src)-1] != '\n' { | |
| 114 | 115 | n++ | |
| 115 | 116 | } | |
| @@ -158,18 +159,35 @@ func tokenise(ctx context.Context, lx chroma.Lexer, src string) (toks []chroma.T | |||
| 158 | 159 | } | |
| 159 | 160 | toks = append(toks, t) | |
| 160 | 161 | } | |
| 161 | - | if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") { | |
| 162 | + | if !tokensMatch(toks, src) { | |
| 162 | 163 | return nil, false | |
| 163 | 164 | } | |
| 164 | 165 | return toks, true | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | - | func joinTokens(toks []chroma.Token) string { | |
| 168 | - | var b strings.Builder | |
| 168 | + | // tokensMatch reports whether the values of the tokens, joined, are src but | |
| 169 | + | // for the line feeds that end either: it compares piece by piece, where | |
| 170 | + | // joining the tokens first would copy the whole file. | |
| 171 | + | func tokensMatch(toks []chroma.Token, src string) bool { | |
| 172 | + | want := strings.TrimRight(src, "\n") | |
| 173 | + | pos := 0 | |
| 169 | 174 | for _, t := range toks { | |
| 170 | - | b.WriteString(t.Value) | |
| 175 | + | v := t.Value | |
| 176 | + | rest := want[pos:] | |
| 177 | + | if len(v) <= len(rest) { | |
| 178 | + | if rest[:len(v)] != v { | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | pos += len(v) | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | // The token reaches past the end of the source: only line feeds may. | |
| 185 | + | if v[:len(rest)] != rest || strings.Trim(v[len(rest):], "\n") != "" { | |
| 186 | + | return false | |
| 187 | + | } | |
| 188 | + | pos = len(want) | |
| 171 | 189 | } | |
| 172 | - | return b.String() | |
| 190 | + | return pos == len(want) | |
| 173 | 191 | } | |
| 174 | 192 | ||
| 175 | 193 | // splitSource splits plain text into lines, without the empty element after | |
M internal/adapters/web/highlight_test.go +50 -5
| @@ -11,10 +11,11 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | func TestHighlightLines(t *testing.T) { | |
| 18 | + | t.Parallel() | |
| 18 | 19 | v := highlight(context.Background(), "a.go", "package main\n\nfunc main() {}\n") | |
| 19 | 20 | assert.Equal(t, 3, v.lines) | |
| 20 | 21 | assert.Equal(t, "Go", v.lang) | |
| @@ -30,6 +31,7 @@ func TestHighlightLines(t *testing.T) { | |||
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | |
| 34 | + | t.Parallel() | |
| 33 | 35 | v := highlight(context.Background(), "unknown.zzz", "<script>alert(1)</script> & \"q\"\n") | |
| 34 | 36 | assert.Equal(t, "", v.lang) | |
| 35 | 37 | h := string(v.html) | |
| @@ -41,6 +43,7 @@ func TestHighlightFallbackIsPlainAndEscaped(t *testing.T) { | |||
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | 45 | func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |
| 46 | + | t.Parallel() | |
| 44 | 47 | for r, cp := range map[rune]string{ | |
| 45 | 48 | 0x202E: "U+202E", 0x2066: "U+2066", 0x2069: "U+2069", 0x200B: "U+200B", 0xFEFF: "U+FEFF", | |
| 46 | 49 | 0x0000: "U+0000", 0x001B: "U+001B", 0x2028: "U+2028", 0x3164: "U+3164", 0x1F: "U+001F", | |
| @@ -63,6 +66,7 @@ func TestHighlightMarksInvisibleCharacters(t *testing.T) { | |||
| 63 | 66 | // The lexers do not lose or add characters: the text of the lines is the | |
| 64 | 67 | // source, whatever the language. | |
| 65 | 68 | func TestHighlightKeepsTheSource(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 66 | 70 | srcs := map[string]string{ | |
| 67 | 71 | "a.go": "package a\n\nvar s = `multi\nline`\n// end", | |
| 68 | 72 | "b.py": "def f():\n '''doc\n string'''\n return 1\n", | |
| @@ -93,6 +97,7 @@ func TestHighlightKeepsTheSource(t *testing.T) { | |||
| 93 | 97 | } | |
| 94 | 98 | ||
| 95 | 99 | func TestFirstLinesAndCount(t *testing.T) { | |
| 100 | + | t.Parallel() | |
| 96 | 101 | assert.Equal(t, "a\nb\n", firstLines("a\nb\nc\n", 2)) | |
| 97 | 102 | assert.Equal(t, "a\nb", firstLines("a\nb", 5)) | |
| 98 | 103 | assert.Equal(t, 3, countLines([]byte("a\nb\nc"))) | |
| @@ -101,6 +106,7 @@ func TestFirstLinesAndCount(t *testing.T) { | |||
| 101 | 106 | } | |
| 102 | 107 | ||
| 103 | 108 | func TestHighlightCtxCancelled(t *testing.T) { | |
| 109 | + | t.Parallel() | |
| 104 | 110 | ctx, cancel := context.WithCancel(context.Background()) | |
| 105 | 111 | cancel() | |
| 106 | 112 | v := highlightCtx(ctx, "a.go", "package a\n") | |
| @@ -116,12 +122,14 @@ func (panicLexer) Tokenise(*chroma.TokeniseOptions, string) (chroma.Iterator, er | |||
| 116 | 122 | } | |
| 117 | 123 | ||
| 118 | 124 | func TestTokeniseRecoversFromALexerPanic(t *testing.T) { | |
| 125 | + | t.Parallel() | |
| 119 | 126 | toks, ok := tokenise(context.Background(), panicLexer{}, "x\n") | |
| 120 | 127 | assert.False(t, ok) | |
| 121 | 128 | assert.Nil(t, toks) | |
| 122 | 129 | } | |
| 123 | 130 | ||
| 124 | 131 | func TestSniffImage(t *testing.T) { | |
| 132 | + | t.Parallel() | |
| 125 | 133 | assert.Equal(t, "image/png", sniffImage([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"))) | |
| 126 | 134 | assert.Equal(t, "image/gif", sniffImage([]byte("GIF89a...."))) | |
| 127 | 135 | assert.Equal(t, "image/jpeg", sniffImage([]byte("\xff\xd8\xff\xe0\x00\x10JFIF"))) | |
| @@ -132,12 +140,13 @@ func TestSniffImage(t *testing.T) { | |||
| 132 | 140 | } | |
| 133 | 141 | ||
| 134 | 142 | func TestLocate(t *testing.T) { | |
| 135 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 143 | + | t.Parallel() | |
| 144 | + | b, tag := domain.RefKindBranch, domain.RefKindTag | |
| 136 | 145 | d := &repoData{ | |
| 137 | - | branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, | |
| 138 | - | tags: []*pb.Ref{ref(tag, "v1/rc")}, | |
| 146 | + | branches: []domain.Ref{ref(b, "main"), ref(b, "feature/x")}, | |
| 147 | + | tags: []domain.Ref{ref(tag, "v1/rc")}, | |
| 139 | 148 | } | |
| 140 | - | d.head = d.branches[0] | |
| 149 | + | d.head = &d.branches[0] | |
| 141 | 150 | for _, tt := range []struct { | |
| 142 | 151 | in, name, path string | |
| 143 | 152 | detached, ok bool | |
| @@ -169,6 +178,7 @@ func TestLocate(t *testing.T) { | |||
| 169 | 178 | } | |
| 170 | 179 | ||
| 171 | 180 | func TestFileCrumbs(t *testing.T) { | |
| 181 | + | t.Parallel() | |
| 172 | 182 | cs := pathCrumbs("demo", "blob", target{name: "feature/x", path: "src/a.go"}) | |
| 173 | 183 | require.Len(t, cs, 5) | |
| 174 | 184 | assert.Equal(t, "/demo/tree/feature/x/src", cs[3].Href) | |
| @@ -218,3 +228,38 @@ func normSpace(s string) string { | |||
| 218 | 228 | } | |
| 219 | 229 | return strings.Join(out, "\n") | |
| 220 | 230 | } | |
| 231 | + | ||
| 232 | + | func TestTokensMatch(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | toks := func(vs ...string) []chroma.Token { | |
| 235 | + | var out []chroma.Token | |
| 236 | + | for _, v := range vs { | |
| 237 | + | out = append(out, chroma.Token{Type: chroma.Text, Value: v}) | |
| 238 | + | } | |
| 239 | + | return out | |
| 240 | + | } | |
| 241 | + | for _, tt := range []struct { | |
| 242 | + | name string | |
| 243 | + | toks []chroma.Token | |
| 244 | + | src string | |
| 245 | + | want bool | |
| 246 | + | }{ | |
| 247 | + | {"same pieces", toks("ab", "c\n", "d"), "abc\nd", true}, | |
| 248 | + | {"lexer added a final line feed", toks("a\n", "b", "\n"), "a\nb", true}, | |
| 249 | + | {"source ends with line feeds", toks("a", "\n"), "a\n\n", true}, | |
| 250 | + | {"empty", nil, "", true}, | |
| 251 | + | {"empty tokens", toks("", ""), "\n", true}, | |
| 252 | + | {"only line feeds", toks("\n\n"), "", true}, | |
| 253 | + | {"token differs", toks("a", "x"), "ab", false}, | |
| 254 | + | {"tokens stop short", toks("a"), "ab", false}, | |
| 255 | + | {"tokens go past the source", toks("abc"), "ab", false}, | |
| 256 | + | {"extra text after the source", toks("ab", "\nx"), "ab", false}, | |
| 257 | + | {"lexer dropped a line", toks("a\n", "c"), "a\nb\nc", false}, | |
| 258 | + | {"unicode", toks("é", "日本"), "é日本", true}, | |
| 259 | + | } { | |
| 260 | + | t.Run(tt.name, func(t *testing.T) { | |
| 261 | + | t.Parallel() | |
| 262 | + | assert.Equal(t, tt.want, tokensMatch(tt.toks, tt.src)) | |
| 263 | + | }) | |
| 264 | + | } | |
| 265 | + | } | |
M internal/adapters/web/home.go +33 -24
| @@ -9,30 +9,39 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | - | "connectrpc.com/connect" | |
| 13 | 12 | "github.com/a-h/templ" | |
| 14 | 13 | ||
| 15 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | 15 | ) | |
| 17 | 16 | ||
| 18 | 17 | // maxRepos bounds the repositories one page lists. | |
| 19 | 18 | const maxRepos = 1000 | |
| 20 | 19 | ||
| 21 | - | // pageSize is the page size asked from the list RPCs: the largest the API | |
| 22 | - | // accepts. | |
| 20 | + | // pageSize is the page size asked from the commit lists: the largest the use | |
| 21 | + | // case accepts for them. | |
| 23 | 22 | const pageSize = 100 | |
| 24 | 23 | ||
| 25 | 24 | // listRepos reads the repositories the visitor may see, ordered by name. | |
| 26 | - | func (s *Server) listRepos(ctx context.Context) ([]*pb.Repo, error) { | |
| 27 | - | var repos []*pb.Repo | |
| 25 | + | // Every page of ListRepos scans the whole repository list and reads the HEAD | |
| 26 | + | // of each repository of the page, so the list is asked in one page of up to | |
| 27 | + | // maxRepos: one scan in the common case, whatever the cap of the use case is | |
| 28 | + | // (a lower one only means more pages, followed by their token). | |
| 29 | + | func (s *Server) listRepos(ctx context.Context) ([]ports.RepoView, error) { | |
| 30 | + | var repos []ports.RepoView | |
| 28 | 31 | token := "" | |
| 29 | 32 | for len(repos) < maxRepos { | |
| 30 | - | res, err := s.cfg.Repos.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{PageSize: pageSize, PageToken: token})) | |
| 33 | + | page, err := s.cfg.Repos.ListPage(ctx, principal(ctx), maxRepos, token) | |
| 31 | 34 | if err != nil { | |
| 32 | 35 | return nil, err | |
| 33 | 36 | } | |
| 34 | - | repos = append(repos, res.Msg.GetRepos()...) | |
| 35 | - | if token = res.Msg.GetNextPageToken(); token == "" { | |
| 37 | + | for _, repo := range page.Repos { | |
| 38 | + | view, err := s.cfg.Repos.View(ctx, repo) | |
| 39 | + | if err != nil { | |
| 40 | + | return nil, err | |
| 41 | + | } | |
| 42 | + | repos = append(repos, view) | |
| 43 | + | } | |
| 44 | + | if token = page.NextPageToken; token == "" { | |
| 36 | 45 | break | |
| 37 | 46 | } | |
| 38 | 47 | } | |
| @@ -48,17 +57,17 @@ var homeCols = []struct{ key, label, class string }{ | |||
| 48 | 57 | } | |
| 49 | 58 | ||
| 50 | 59 | // sortRepos orders repos by the column key; ties keep the order by name. | |
| 51 | - | func sortRepos(repos []*pb.Repo, key string, desc bool) { | |
| 52 | - | less := func(a, b *pb.Repo) bool { | |
| 60 | + | func sortRepos(repos []ports.RepoView, key string, desc bool) { | |
| 61 | + | less := func(a, b ports.RepoView) bool { | |
| 53 | 62 | switch key { | |
| 54 | 63 | case "desc": | |
| 55 | - | return strings.ToLower(a.GetDescription()) < strings.ToLower(b.GetDescription()) | |
| 64 | + | return strings.ToLower(a.Description) < strings.ToLower(b.Description) | |
| 56 | 65 | case "branch": | |
| 57 | - | return a.GetDefaultBranch() < b.GetDefaultBranch() | |
| 66 | + | return a.DefaultBranch < b.DefaultBranch | |
| 58 | 67 | case "push": | |
| 59 | - | return a.GetPushedAt().AsTime().Before(b.GetPushedAt().AsTime()) | |
| 68 | + | return a.PushedAt.Before(b.PushedAt) | |
| 60 | 69 | } | |
| 61 | - | return a.GetName() < b.GetName() | |
| 70 | + | return a.Name.String() < b.Name.String() | |
| 62 | 71 | } | |
| 63 | 72 | sort.SliceStable(repos, func(i, j int) bool { | |
| 64 | 73 | if desc { | |
| @@ -114,25 +123,25 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 114 | 123 | } | |
| 115 | 124 | ||
| 116 | 125 | // homeRow is the row of a repository in the home list. | |
| 117 | - | func homeRow(r *pb.Repo, now time.Time, cursor bool) HomeRow { | |
| 126 | + | func homeRow(r ports.RepoView, now time.Time, cursor bool) HomeRow { | |
| 127 | + | name := r.Name.String() | |
| 118 | 128 | row := HomeRow{ | |
| 119 | - | ID: "repo-" + r.GetName(), Name: r.GetName(), Href: repoHref(r.GetName()), | |
| 120 | - | Desc: r.GetDescription(), Pushed: "never", Branch: r.GetDefaultBranch(), Cursor: cursor, | |
| 129 | + | ID: "repo-" + name, Name: name, Href: repoHref(name), | |
| 130 | + | Desc: r.Description, Pushed: "never", Branch: r.DefaultBranch, Cursor: cursor, | |
| 121 | 131 | } | |
| 122 | - | if r.GetPushedAt().IsValid() && r.GetPushedAt().GetSeconds() != 0 { | |
| 123 | - | t := r.GetPushedAt().AsTime() | |
| 124 | - | row.Pushed, row.PushedAt = ago(now, t), rfc3339(t) | |
| 132 | + | if !r.PushedAt.IsZero() { | |
| 133 | + | row.Pushed, row.PushedAt = ago(now, r.PushedAt), rfc3339(r.PushedAt) | |
| 125 | 134 | } | |
| 126 | 135 | return row | |
| 127 | 136 | } | |
| 128 | 137 | ||
| 129 | 138 | // repoCmds are the repositories as targets of the ":" command line, but the | |
| 130 | 139 | // one being viewed. | |
| 131 | - | func repoCmds(repos []*pb.Repo, except string) []CmdItem { | |
| 140 | + | func repoCmds(repos []ports.RepoView, except string) []CmdItem { | |
| 132 | 141 | var out []CmdItem | |
| 133 | 142 | for _, r := range repos { | |
| 134 | - | if r.GetName() != except { | |
| 135 | - | out = append(out, CmdItem{Kind: "repo", Label: r.GetName(), Href: repoHref(r.GetName())}) | |
| 143 | + | if name := r.Name.String(); name != except { | |
| 144 | + | out = append(out, CmdItem{Kind: "repo", Label: name, Href: repoHref(name)}) | |
| 136 | 145 | } | |
| 137 | 146 | } | |
| 138 | 147 | return out | |
M internal/adapters/web/keys.templ +23 -63
| @@ -3,72 +3,32 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | // Keys is the keyboard map. ermite.js turns keys into k-<name> events on | |
| 6 | - | // <body>; this element (hx-on + hx-live helpers) is the only place that | |
| 7 | - | // reacts to them. | |
| 6 | + | // <body>; this element is the only place that binds them, each to one call of | |
| 7 | + | // window.ermite (ermite.js), which does the work. | |
| 8 | 8 | templ Keys() { | |
| 9 | 9 | <div | |
| 10 | 10 | id="keys" | |
| 11 | 11 | hidden | |
| 12 | - | hx-on="k-j from:body -> (async (d) => { | |
| 13 | - | if (q('dialog[open]').count) return; | |
| 14 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 15 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 16 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 17 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 18 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 19 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 20 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 21 | - | })(1); | |
| 22 | - | k-k from:body -> (async (d) => { | |
| 23 | - | if (q('dialog[open]').count) return; | |
| 24 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 25 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 26 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 27 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 28 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 29 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 30 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 31 | - | })(-1); | |
| 32 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 33 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 34 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 35 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 36 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 37 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 38 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 39 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 40 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 41 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 42 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 43 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 44 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 45 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 46 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 47 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 48 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 49 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 50 | - | k-next from:body -> { | |
| 51 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 52 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 53 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 54 | - | }; | |
| 55 | - | k-split from:body -> { | |
| 56 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 57 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 58 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 59 | - | }; | |
| 60 | - | k-unified from:body -> { | |
| 61 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 62 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 63 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 64 | - | }; | |
| 65 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 66 | - | k-permalink from:body -> { | |
| 67 | - | let b = document.body.dataset; | |
| 68 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 69 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 70 | - | navigator.clipboard?.writeText(location.href); | |
| 71 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 72 | - | }" | |
| 12 | + | hx-on="k-j from:body -> ermite.move(1); | |
| 13 | + | k-k from:body -> ermite.move(-1); | |
| 14 | + | k-top from:body -> ermite.edge(false); | |
| 15 | + | k-bottom from:body -> ermite.edge(true); | |
| 16 | + | k-open from:body -> ermite.open(); | |
| 17 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 18 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 19 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 20 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 21 | + | k-filter from:body -> ermite.focusFilter(); | |
| 22 | + | k-gh from:body -> ermite.go('.brand'); | |
| 23 | + | k-s from:body -> ermite.tab('s'); | |
| 24 | + | k-t from:body -> ermite.tab('t'); | |
| 25 | + | k-c from:body -> ermite.tab('c'); | |
| 26 | + | k-r from:body -> ermite.tab('r'); | |
| 27 | + | k-m from:body -> ermite.tab('m'); | |
| 28 | + | k-next from:body -> ermite.nextNote(); | |
| 29 | + | k-split from:body -> ermite.layout('split'); | |
| 30 | + | k-unified from:body -> ermite.layout('unified'); | |
| 31 | + | k-collapse from:body -> ermite.collapse(); | |
| 32 | + | k-permalink from:body -> ermite.permalink()" | |
| 73 | 33 | ></div> | |
| 74 | 34 | } | |
M internal/adapters/web/keys_templ.go +3 -3 generated file: collapsed
M internal/adapters/web/markdown_test.go +6 -0
| @@ -19,6 +19,7 @@ func renderMD(t *testing.T, src string) (string, []Heading) { | |||
| 19 | 19 | ||
| 20 | 20 | // AC4: raw HTML, javascript: links and SVG are neutralised. | |
| 21 | 21 | func TestMarkdownNeutralisation(t *testing.T) { | |
| 22 | + | t.Parallel() | |
| 22 | 23 | tests := []struct { | |
| 23 | 24 | name, src string | |
| 24 | 25 | absent []string | |
| @@ -59,6 +60,7 @@ func TestMarkdownNeutralisation(t *testing.T) { | |||
| 59 | 60 | } | |
| 60 | 61 | for _, tt := range tests { | |
| 61 | 62 | t.Run(tt.name, func(t *testing.T) { | |
| 63 | + | t.Parallel() | |
| 62 | 64 | out, _ := renderMD(t, tt.src) | |
| 63 | 65 | for _, s := range tt.absent { | |
| 64 | 66 | assert.NotContains(t, out, s) | |
| @@ -80,6 +82,7 @@ func TestMarkdownNeutralisation(t *testing.T) { | |||
| 80 | 82 | } | |
| 81 | 83 | ||
| 82 | 84 | func TestMarkdownLinks(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 83 | 86 | tests := []struct{ name, src, href string }{ | |
| 84 | 87 | {"relative from the file's directory", "[a](other.md)", `href="/demo/blob/main/docs/other.md"`}, | |
| 85 | 88 | {"parent", "[a](../LICENSE)", `href="/demo/blob/main/LICENSE"`}, | |
| @@ -95,6 +98,7 @@ func TestMarkdownLinks(t *testing.T) { | |||
| 95 | 98 | } | |
| 96 | 99 | for _, tt := range tests { | |
| 97 | 100 | t.Run(tt.name, func(t *testing.T) { | |
| 101 | + | t.Parallel() | |
| 98 | 102 | out, _ := renderMD(t, tt.src) | |
| 99 | 103 | if tt.href == "" { | |
| 100 | 104 | assert.NotContains(t, out, "<a ") | |
| @@ -106,6 +110,7 @@ func TestMarkdownLinks(t *testing.T) { | |||
| 106 | 110 | } | |
| 107 | 111 | ||
| 108 | 112 | func TestMarkdownOutline(t *testing.T) { | |
| 113 | + | t.Parallel() | |
| 109 | 114 | out, outline := renderMD(t, "# Title\n\n## Install `now`\n\n### Install `now`\n\ntext\n\n#### raw *em*\n") | |
| 110 | 115 | assert.Equal(t, []Heading{ | |
| 111 | 116 | {Level: 1, ID: "md-title", Text: "Title"}, | |
| @@ -124,6 +129,7 @@ func TestMarkdownOutline(t *testing.T) { | |||
| 124 | 129 | } | |
| 125 | 130 | ||
| 126 | 131 | func TestMarkdownGFM(t *testing.T) { | |
| 132 | + | t.Parallel() | |
| 127 | 133 | out, _ := renderMD(t, "| a | b |\n|:-|-:|\n| 1 | 2 |\n\n~~gone~~\n\n- [x] task\n") | |
| 128 | 134 | assert.Contains(t, out, `<th align="right">b</th>`, "alignment is an attribute, never a style") | |
| 129 | 135 | assert.NotContains(t, out, "style=") | |
M internal/adapters/web/mr.go +111 -116
| @@ -11,14 +11,14 @@ import ( | |||
| 11 | 11 | "strconv" | |
| 12 | 12 | "strings" | |
| 13 | 13 | ||
| 14 | - | "connectrpc.com/connect" | |
| 15 | 14 | "github.com/a-h/templ" | |
| 16 | 15 | ||
| 17 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | // The merge request pages (ADR 0017). They read through MergeRequestService | |
| 21 | - | // and GitService as the visitor of the request: anonymous sees the merge | |
| 21 | + | // and BrowseService as the visitor of the request: anonymous sees the merge | |
| 22 | 22 | // requests of public repositories, the signed-in owner those of every | |
| 23 | 23 | // repository, and only the owner gets the forms (mr_post.go handles them). | |
| 24 | 24 | // | |
| @@ -44,9 +44,9 @@ import ( | |||
| 44 | 44 | ||
| 45 | 45 | const ( | |
| 46 | 46 | // mrPerPage is how many decided merge requests a page of the list shows, | |
| 47 | - | // at least; pages of the API are read whole. | |
| 47 | + | // at least; pages of the use case are read whole. | |
| 48 | 48 | mrPerPage = 50 | |
| 49 | - | // maxMRPages bounds the API pages one list page reads. | |
| 49 | + | // maxMRPages bounds the pages of the use case one list page reads. | |
| 50 | 50 | maxMRPages = 20 | |
| 51 | 51 | // maxOpenPages bounds the pages read for the open merge requests, which | |
| 52 | 52 | // the first list page shows in full. | |
| @@ -260,39 +260,39 @@ func mrNumber(r *http.Request) (int64, error) { | |||
| 260 | 260 | return n, nil | |
| 261 | 261 | } | |
| 262 | 262 | ||
| 263 | - | func stateName(s pb.MergeRequestState) string { | |
| 263 | + | func stateName(s domain.MRState) string { | |
| 264 | 264 | switch s { | |
| 265 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN: | |
| 265 | + | case domain.MRStateOpen: | |
| 266 | 266 | return "open" | |
| 267 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED: | |
| 267 | + | case domain.MRStateMerged: | |
| 268 | 268 | return "merged" | |
| 269 | - | case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED: | |
| 269 | + | case domain.MRStateClosed: | |
| 270 | 270 | return "closed" | |
| 271 | 271 | } | |
| 272 | 272 | return "unknown" | |
| 273 | 273 | } | |
| 274 | 274 | ||
| 275 | - | func modeName(m pb.MergeMode) string { | |
| 275 | + | func modeName(m domain.MergeMode) string { | |
| 276 | 276 | switch m { | |
| 277 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | |
| 277 | + | case domain.MergeModeFastForward: | |
| 278 | 278 | return "fast-forward" | |
| 279 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | |
| 279 | + | case domain.MergeModeSquash: | |
| 280 | 280 | return "squash" | |
| 281 | 281 | } | |
| 282 | 282 | return "" | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | 285 | // mrRow converts a merge request of a listing. | |
| 286 | - | func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | |
| 286 | + | func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow { | |
| 287 | 287 | row := MRRow{ | |
| 288 | - | Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()), | |
| 289 | - | Title: visibleText(m.GetTitle()), State: stateName(m.GetState()), | |
| 290 | - | Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()), | |
| 291 | - | Author: visibleText(m.GetAuthor()), | |
| 288 | + | Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number), | |
| 289 | + | Title: visibleText(m.Title.String()), State: stateName(m.State), | |
| 290 | + | Branches: visibleText(m.Source) + " → " + visibleText(m.Target), | |
| 291 | + | Author: visibleText(m.Author), | |
| 292 | 292 | } | |
| 293 | - | ts := m.GetUpdatedAt() | |
| 294 | - | if !ts.IsValid() { | |
| 295 | - | ts = m.GetCreatedAt() | |
| 293 | + | ts := m.UpdatedAt | |
| 294 | + | if ts.IsZero() { | |
| 295 | + | ts = m.CreatedAt | |
| 296 | 296 | } | |
| 297 | 297 | row.When, row.WhenAt = whenOf(s.cfg.Now(), ts) | |
| 298 | 298 | return row | |
| @@ -300,30 +300,30 @@ func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow { | |||
| 300 | 300 | ||
| 301 | 301 | // mrList lists the merge requests: the open ones, then the merged and closed | |
| 302 | 302 | // ones, a page at a time. The decided ones are paged with the page token of | |
| 303 | - | // the API in "page"; the open ones show on the first page only. | |
| 303 | + | // the use case in "page"; the open ones show on the first page only. | |
| 304 | 304 | func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 305 | 305 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 306 | 306 | if err != nil { | |
| 307 | 307 | return nil, 0, err | |
| 308 | 308 | } | |
| 309 | 309 | token := r.URL.Query().Get("page") | |
| 310 | - | p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())} | |
| 310 | + | p := MRListPage{Default: visibleText(d.repo.DefaultBranch)} | |
| 311 | 311 | p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}} | |
| 312 | 312 | base := mrBase(d.name()) | |
| 313 | 313 | ||
| 314 | 314 | if token == "" { | |
| 315 | 315 | tok := "" | |
| 316 | 316 | for i := 0; i < maxOpenPages; i++ { | |
| 317 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | |
| 318 | - | Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok, | |
| 319 | - | })) | |
| 317 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ | |
| 318 | + | State: domain.MRStateOpen, PageSize: pageSize, PageToken: tok, | |
| 319 | + | }) | |
| 320 | 320 | if err != nil { | |
| 321 | 321 | return nil, 0, err | |
| 322 | 322 | } | |
| 323 | - | for _, m := range res.Msg.GetMergeRequests() { | |
| 323 | + | for _, m := range res.MergeRequests { | |
| 324 | 324 | p.Open = append(p.Open, s.mrRow(d.name(), m)) | |
| 325 | 325 | } | |
| 326 | - | if tok = res.Msg.GetNextPageToken(); tok == "" { | |
| 326 | + | if tok = res.NextPageToken; tok == "" { | |
| 327 | 327 | break | |
| 328 | 328 | } | |
| 329 | 329 | } | |
| @@ -334,18 +334,18 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||
| 334 | 334 | ||
| 335 | 335 | tok := token | |
| 336 | 336 | for i := 0; i < maxMRPages; i++ { | |
| 337 | - | res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{ | |
| 338 | - | Repo: d.name(), PageSize: pageSize, PageToken: tok, | |
| 339 | - | })) | |
| 337 | + | res, err := s.cfg.MergeRequests.List(ctx, principal(ctx), d.repo.Name, ports.ListMergeRequestsParams{ | |
| 338 | + | PageSize: pageSize, PageToken: tok, | |
| 339 | + | }) | |
| 340 | 340 | if err != nil { | |
| 341 | 341 | return nil, 0, err | |
| 342 | 342 | } | |
| 343 | - | for _, m := range res.Msg.GetMergeRequests() { | |
| 344 | - | if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN { | |
| 343 | + | for _, m := range res.MergeRequests { | |
| 344 | + | if m.State != domain.MRStateOpen { | |
| 345 | 345 | p.Done = append(p.Done, s.mrRow(d.name(), m)) | |
| 346 | 346 | } | |
| 347 | 347 | } | |
| 348 | - | tok = res.Msg.GetNextPageToken() | |
| 348 | + | tok = res.NextPageToken | |
| 349 | 349 | if tok == "" || len(p.Done) >= mrPerPage { | |
| 350 | 350 | break | |
| 351 | 351 | } | |
| @@ -363,10 +363,10 @@ func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, | |||
| 363 | 363 | p.Done[0].Cursor = true | |
| 364 | 364 | } | |
| 365 | 365 | for _, b := range d.branches { | |
| 366 | - | if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches { | |
| 366 | + | if b.ShortName == d.repo.DefaultBranch || len(p.Branches) >= railBranches { | |
| 367 | 367 | continue | |
| 368 | 368 | } | |
| 369 | - | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())}) | |
| 369 | + | p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.ShortName), Href: repoHref(d.name(), "compare", b.ShortName)}) | |
| 370 | 370 | } | |
| 371 | 371 | ||
| 372 | 372 | l := s.repoLayout(ctx, d, "merge-requests") | |
| @@ -391,6 +391,7 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||
| 391 | 391 | return s.mrPage(ctx, r, true) | |
| 392 | 392 | } | |
| 393 | 393 | ||
| 394 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here | |
| 394 | 395 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | |
| 395 | 396 | n, err := mrNumber(r) | |
| 396 | 397 | if err != nil { | |
| @@ -404,79 +405,72 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 404 | 405 | if err != nil { | |
| 405 | 406 | return nil, 0, err | |
| 406 | 407 | } | |
| 407 | - | res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{ | |
| 408 | - | Repo: d.name(), Number: n, SinceLastRead: since, | |
| 409 | - | })) | |
| 408 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since) | |
| 410 | 409 | if err != nil { | |
| 411 | 410 | return nil, 0, err | |
| 412 | 411 | } | |
| 413 | - | m := res.Msg.GetMergeRequest() | |
| 414 | - | if m == nil { | |
| 415 | - | return nil, 0, errNotFound | |
| 416 | - | } | |
| 412 | + | m := res.MergeRequest | |
| 417 | 413 | now := s.cfg.Now() | |
| 418 | 414 | signed := SignedIn(ctx) | |
| 419 | - | open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN | |
| 420 | - | head := m.GetHeadId() | |
| 415 | + | open := m.State == domain.MRStateOpen | |
| 416 | + | head := m.HeadID.String() | |
| 421 | 417 | self := mrHref(d.name(), n) | |
| 422 | 418 | if since { | |
| 423 | 419 | self = mrHref(d.name(), n, "since") | |
| 424 | 420 | } | |
| 425 | 421 | ||
| 426 | 422 | p := MRPage{ | |
| 427 | - | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()), | |
| 428 | - | State: stateName(m.GetState()), Open: open, Signed: signed, | |
| 429 | - | Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()), | |
| 430 | - | Author: visibleText(m.GetAuthor()), | |
| 431 | - | Desc: plainParts(m.GetDescription()), | |
| 423 | + | Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()), | |
| 424 | + | State: stateName(m.State), Open: open, Signed: signed, | |
| 425 | + | Source: visibleText(m.Source), Target: visibleText(m.Target), | |
| 426 | + | Author: visibleText(m.Author), | |
| 427 | + | Desc: plainParts(m.Description.String()), | |
| 432 | 428 | Head: head, HeadShort: short(head), | |
| 433 | - | NFiles: int(res.Msg.GetStats().GetFilesChanged()), | |
| 434 | - | Adds: int(res.Msg.GetStats().GetAdditions()), | |
| 435 | - | Dels: int(res.Msg.GetStats().GetDeletions()), | |
| 436 | - | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | |
| 429 | + | NFiles: res.Stats.FilesChanged, | |
| 430 | + | Adds: res.Stats.Additions, | |
| 431 | + | Dels: res.Stats.Deletions, | |
| 432 | + | Files: diffFiles(res.Diff.Files), | |
| 437 | 433 | } | |
| 438 | 434 | p.Opened.Name = p.Author | |
| 439 | - | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt()) | |
| 440 | - | if t := m.GetCreatedAt(); t.IsValid() { | |
| 441 | - | p.Opened.Abs = absolute(t.AsTime()) | |
| 435 | + | p.Opened.When, p.Opened.WhenAt = whenOf(now, m.CreatedAt) | |
| 436 | + | if !m.CreatedAt.IsZero() { | |
| 437 | + | p.Opened.Abs = absolute(m.CreatedAt) | |
| 442 | 438 | } | |
| 443 | - | p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt()) | |
| 439 | + | p.Updated, p.UpdatedAt = whenOf(now, m.UpdatedAt) | |
| 444 | 440 | for _, b := range d.branches { | |
| 445 | - | switch b.GetShortName() { | |
| 446 | - | case m.GetSourceBranch(): | |
| 447 | - | p.SourceHref = repoHref(d.name(), "tree", b.GetShortName()) | |
| 448 | - | case m.GetTargetBranch(): | |
| 449 | - | p.TargetHref = repoHref(d.name(), "tree", b.GetShortName()) | |
| 441 | + | switch b.ShortName { | |
| 442 | + | case m.Source: | |
| 443 | + | p.SourceHref = repoHref(d.name(), "tree", b.ShortName) | |
| 444 | + | case m.Target: | |
| 445 | + | p.TargetHref = repoHref(d.name(), "tree", b.ShortName) | |
| 450 | 446 | } | |
| 451 | 447 | } | |
| 452 | - | if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" { | |
| 453 | - | p.MergedShort = short(m.GetMergedCommitId()) | |
| 454 | - | p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId()) | |
| 455 | - | p.MergeMode = modeName(m.GetMergeMode()) | |
| 448 | + | if m.State == domain.MRStateMerged && !m.MergedCommitID.IsZero() { | |
| 449 | + | p.MergedShort = short(m.MergedCommitID.String()) | |
| 450 | + | p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String()) | |
| 451 | + | p.MergeMode = modeName(m.MergeMode) | |
| 456 | 452 | } | |
| 457 | - | if st := d.repo.GetSettings(); st != nil { | |
| 458 | - | for _, mode := range st.GetAllowedMergeModes() { | |
| 459 | - | if name := modeName(mode); name != "" { | |
| 460 | - | p.Modes = append(p.Modes, name) | |
| 461 | - | } | |
| 453 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 454 | + | if name := modeName(mode); name != "" { | |
| 455 | + | p.Modes = append(p.Modes, name) | |
| 462 | 456 | } | |
| 463 | - | p.ChecksBlock = st.GetFailingChecksBlock() | |
| 464 | 457 | } | |
| 465 | - | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | |
| 458 | + | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | |
| 459 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 466 | 460 | ||
| 467 | 461 | // The commits of the request, newest first. | |
| 468 | - | for _, c := range res.Msg.GetCommits() { | |
| 462 | + | for _, c := range res.Commits { | |
| 469 | 463 | row := commitRow(d.name(), c, now) | |
| 470 | 464 | p.Commits = append(p.Commits, MRCommit{ | |
| 471 | 465 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | |
| 472 | 466 | }) | |
| 473 | 467 | } | |
| 474 | - | lastRead := res.Msg.GetLastReadId() | |
| 468 | + | lastRead := res.LastReadID.String() | |
| 475 | 469 | if signed && lastRead != "" { | |
| 476 | 470 | // The commits are newest first: those before the last read head are new. | |
| 477 | 471 | // A head that is not among them (the branch was rewritten) marks none. | |
| 478 | - | for i, c := range res.Msg.GetCommits() { | |
| 479 | - | if c.GetId() != lastRead { | |
| 472 | + | for i, c := range res.Commits { | |
| 473 | + | if c.ID.String() != lastRead { | |
| 480 | 474 | continue | |
| 481 | 475 | } | |
| 482 | 476 | for j := 0; j < i; j++ { | |
| @@ -484,7 +478,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 484 | 478 | } | |
| 485 | 479 | } | |
| 486 | 480 | } | |
| 487 | - | if res.Msg.GetCommitsTruncated() { | |
| 481 | + | if res.CommitsTruncated { | |
| 488 | 482 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | |
| 489 | 483 | } | |
| 490 | 484 | ||
| @@ -502,7 +496,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 502 | 496 | switch { | |
| 503 | 497 | case lastRead == "": | |
| 504 | 498 | p.SinceNote = "nothing was marked read yet: this is the whole change." | |
| 505 | - | case res.Msg.GetLastReadLost(): | |
| 499 | + | case res.LastReadLost: | |
| 506 | 500 | p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again." | |
| 507 | 501 | case len(p.Files) == 0: | |
| 508 | 502 | p.SinceNote = "nothing changed since " + short(lastRead) + "." | |
| @@ -521,12 +515,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 521 | 515 | if canWrite { | |
| 522 | 516 | on = parseOn(r.URL.Query().Get("on")) | |
| 523 | 517 | } | |
| 524 | - | s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on) | |
| 518 | + | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 525 | 519 | lines, invisible := diffLines(p.Files) | |
| 526 | 520 | p.Invisible = invisible | |
| 527 | 521 | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | |
| 528 | 522 | if signed && open { | |
| 529 | - | p.Box = s.mergeBox(d, n, m.GetSourceBranch(), head, lastRead) | |
| 523 | + | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | |
| 530 | 524 | } | |
| 531 | 525 | if canWrite { | |
| 532 | 526 | p.GeneralForm = &CommentForm{ | |
| @@ -537,8 +531,8 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 537 | 531 | l := s.repoLayout(ctx, d, "merge-requests") | |
| 538 | 532 | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |
| 539 | 533 | if head != "" { | |
| 540 | - | pinRef(&l, target{rev: head, name: m.GetSourceBranch()}) | |
| 541 | - | if !d.hasBranch(m.GetSourceBranch()) { | |
| 534 | + | pinRef(&l, target{rev: head, name: m.Source}) | |
| 535 | + | if !d.hasBranch(m.Source) { | |
| 542 | 536 | pinRef(&l, target{rev: head, name: short(head), detached: true}) | |
| 543 | 537 | } | |
| 544 | 538 | } | |
| @@ -551,7 +545,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 551 | 545 | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |
| 552 | 546 | } | |
| 553 | 547 | p.Live = true | |
| 554 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch())) | |
| 548 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)) | |
| 555 | 549 | l.HasRail = true | |
| 556 | 550 | return MergeRequest(l, p), http.StatusOK, nil | |
| 557 | 551 | } | |
| @@ -559,7 +553,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 559 | 553 | // hasBranch tells whether the repository has the branch. | |
| 560 | 554 | func (d *repoData) hasBranch(name string) bool { | |
| 561 | 555 | for _, b := range d.branches { | |
| 562 | - | if b.GetShortName() == name { | |
| 556 | + | if b.ShortName == name { | |
| 563 | 557 | return true | |
| 564 | 558 | } | |
| 565 | 559 | } | |
| @@ -572,19 +566,17 @@ func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) * | |||
| 572 | 566 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | |
| 573 | 567 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | |
| 574 | 568 | CanMarkRead: head != "" && head != lastRead, | |
| 575 | - | CanDeleteBranch: source != d.repo.GetDefaultBranch(), | |
| 569 | + | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 576 | 570 | } | |
| 577 | 571 | ff, sq := false, false | |
| 578 | - | if st := d.repo.GetSettings(); st != nil { | |
| 579 | - | for _, mode := range st.GetAllowedMergeModes() { | |
| 580 | - | switch mode { | |
| 581 | - | case pb.MergeMode_MERGE_MODE_FAST_FORWARD: | |
| 582 | - | ff = true | |
| 583 | - | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | |
| 584 | - | case pb.MergeMode_MERGE_MODE_SQUASH: | |
| 585 | - | sq = true | |
| 586 | - | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |
| 587 | - | } | |
| 572 | + | for _, mode := range d.repo.Settings.AllowedMergeModes { | |
| 573 | + | switch mode { | |
| 574 | + | case domain.MergeModeFastForward: | |
| 575 | + | ff = true | |
| 576 | + | b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"}) | |
| 577 | + | case domain.MergeModeSquash: | |
| 578 | + | sq = true | |
| 579 | + | b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"}) | |
| 588 | 580 | } | |
| 589 | 581 | } | |
| 590 | 582 | if head != "" { | |
| @@ -637,14 +629,16 @@ func parseOn(s string) *lineRef { | |||
| 637 | 629 | // the target, so a page that shows another diff (since the last read) lists | |
| 638 | 630 | // them all below, with their line. A reader who can write gets the line numbers | |
| 639 | 631 | // as links to the form of their line; on designates the line whose form shows. | |
| 640 | - | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) { | |
| 632 | + | // | |
| 633 | + | //hexcheck:ignore suspicious-business-logic-in-adapter places comments under the lines of the diff shown; display only | |
| 634 | + | func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domain.Comment, head string, inline, canWrite bool, on *lineRef) { | |
| 641 | 635 | now := s.cfg.Now() | |
| 642 | 636 | self := mrHref(repo, n) | |
| 643 | 637 | resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") } | |
| 644 | 638 | ||
| 645 | 639 | type lineKey struct { | |
| 646 | 640 | path string | |
| 647 | - | side pb.DiffSide | |
| 641 | + | side domain.DiffSide | |
| 648 | 642 | line int | |
| 649 | 643 | } | |
| 650 | 644 | index := map[lineKey]*DiffLine{} | |
| @@ -657,13 +651,13 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||
| 657 | 651 | for li := range f.Hunks[hi].Lines { | |
| 658 | 652 | ln := &f.Hunks[hi].Lines[li] | |
| 659 | 653 | if ln.oldN > 0 && ln.Kind != "add" { | |
| 660 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln | |
| 654 | + | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln | |
| 661 | 655 | if linkable { | |
| 662 | 656 | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 663 | 657 | } | |
| 664 | 658 | } | |
| 665 | 659 | if ln.newN > 0 && ln.Kind != "del" { | |
| 666 | - | index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln | |
| 660 | + | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln | |
| 667 | 661 | if linkable { | |
| 668 | 662 | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 669 | 663 | } | |
| @@ -671,7 +665,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||
| 671 | 665 | } | |
| 672 | 666 | } | |
| 673 | 667 | } | |
| 674 | - | find := func(path string, side pb.DiffSide, line int) *DiffLine { | |
| 668 | + | find := func(path string, side domain.DiffSide, line int) *DiffLine { | |
| 675 | 669 | if !inline { | |
| 676 | 670 | return nil | |
| 677 | 671 | } | |
| @@ -679,34 +673,35 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||
| 679 | 673 | } | |
| 680 | 674 | ||
| 681 | 675 | for _, c := range comments { | |
| 676 | + | cid := strconv.FormatInt(c.ID, 10) | |
| 682 | 677 | v := CommentView{ | |
| 683 | - | ID: c.GetId(), Author: visibleText(c.GetAuthor()), | |
| 684 | - | Parts: plainParts(c.GetText()), Resolved: c.GetResolved(), | |
| 685 | - | Outdated: c.GetCommitId() != "" && c.GetCommitId() != head, | |
| 678 | + | ID: cid, Author: visibleText(c.Author), | |
| 679 | + | Parts: plainParts(c.Text.String()), Resolved: c.Resolved, | |
| 680 | + | Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head, | |
| 686 | 681 | } | |
| 687 | - | v.When, v.WhenAt = whenOf(now, c.GetCreatedAt()) | |
| 688 | - | if t := c.GetCreatedAt(); t.IsValid() { | |
| 689 | - | v.Abs = absolute(t.AsTime()) | |
| 682 | + | v.When, v.WhenAt = whenOf(now, c.CreatedAt) | |
| 683 | + | if !c.CreatedAt.IsZero() { | |
| 684 | + | v.Abs = absolute(c.CreatedAt) | |
| 690 | 685 | } | |
| 691 | 686 | p.NComments++ | |
| 692 | - | if !c.GetResolved() { | |
| 687 | + | if !c.Resolved { | |
| 693 | 688 | p.NUnresolved++ | |
| 694 | 689 | } | |
| 695 | - | general := c.GetPath() == "" | |
| 690 | + | general := c.Path == "" | |
| 696 | 691 | var ln *DiffLine | |
| 697 | 692 | if !general { | |
| 698 | - | ln = find(c.GetPath(), c.GetSide(), int(c.GetLine())) | |
| 693 | + | ln = find(c.Path, c.Side, c.Line) | |
| 699 | 694 | if ln == nil { | |
| 700 | 695 | side := "new" | |
| 701 | - | if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD { | |
| 696 | + | if c.Side == domain.SideOld { | |
| 702 | 697 | side = "old" | |
| 703 | 698 | } | |
| 704 | - | v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")" | |
| 699 | + | v.Where = visibleText(c.Path) + ":" + itoa(c.Line) + " (" + side + ")" | |
| 705 | 700 | } | |
| 706 | 701 | } | |
| 707 | 702 | if canWrite { | |
| 708 | - | v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "Resolve"} | |
| 709 | - | if c.GetResolved() { | |
| 703 | + | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} | |
| 704 | + | if c.Resolved { | |
| 710 | 705 | v.Resolve.Label = "Unresolve" | |
| 711 | 706 | } | |
| 712 | 707 | } | |
| @@ -721,9 +716,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.C | |||
| 721 | 716 | } | |
| 722 | 717 | ||
| 723 | 718 | if on != nil { | |
| 724 | - | side := pb.DiffSide_DIFF_SIDE_NEW | |
| 719 | + | side := domain.SideNew | |
| 725 | 720 | if on.side == "old" { | |
| 726 | - | side = pb.DiffSide_DIFF_SIDE_OLD | |
| 721 | + | side = domain.SideOld | |
| 727 | 722 | } | |
| 728 | 723 | if ln := find(on.path, side, on.line); ln != nil { | |
| 729 | 724 | ln.Form = &CommentForm{ | |
M internal/adapters/web/mr.templ +4 -5
| @@ -9,18 +9,17 @@ import "strings" | |||
| 9 | 9 | templ MRList(l Layout, p MRListPage) { | |
| 10 | 10 | @Shell(l, mrListRail(p)) { | |
| 11 | 11 | if p.Live { | |
| 12 | - | <div id="mr-notice"></div> | |
| 12 | + | @LiveSlot("mr-notice") | |
| 13 | 13 | } | |
| 14 | 14 | <div class="filter"> | |
| 15 | 15 | <label for="mr-filter" class="sr">filter</label> | |
| 16 | 16 | <input | |
| 17 | 17 | id="mr-filter" | |
| 18 | 18 | type="search" | |
| 19 | - | data-filter | |
| 19 | + | data-filter=".mrs tbody tr" | |
| 20 | 20 | placeholder="Filter by title, branch, author or state…" | |
| 21 | 21 | autocomplete="off" | |
| 22 | - | hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 23 | - | let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')" | |
| 22 | + | hx-on:input="ermite.filter(this)" | |
| 24 | 23 | /> | |
| 25 | 24 | </div> | |
| 26 | 25 | if p.Newest == "" { | |
| @@ -140,7 +139,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 140 | 139 | } | |
| 141 | 140 | </dl> | |
| 142 | 141 | </header> | |
| 143 | - | <div id="mr-notice"></div> | |
| 142 | + | @LiveSlot("mr-notice") | |
| 144 | 143 | if len(p.Desc) > 0 { | |
| 145 | 144 | <p class="mr-desc">@userText(p.Desc)</p> | |
| 146 | 145 | } | |
M internal/adapters/web/mr_post.go +139 -71
| @@ -5,24 +5,23 @@ package web | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | - | "fmt" | |
| 9 | 8 | "net/http" | |
| 10 | 9 | "strconv" | |
| 11 | 10 | "strings" | |
| 12 | 11 | ||
| 13 | - | "connectrpc.com/connect" | |
| 14 | 12 | "github.com/bnema/zerowrap" | |
| 15 | 13 | ||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | 16 | ) | |
| 18 | 17 | ||
| 19 | 18 | // The writes of the merge request pages. Each is a POST that passed the gate | |
| 20 | - | // (an Origin of this site and the owner's session), calls the API as the | |
| 19 | + | // (an Origin of this site and the owner's session), calls the use case as the | |
| 21 | 20 | // owner, and answers 303 to the page that shows the result, with | |
| 22 | - | // "Cache-Control: no-store". A request the API refuses is answered with a | |
| 23 | - | // state page that says why, in the words of the API for the rules the owner | |
| 24 | - | // can fix (a text rule, a moved branch, a full merge request), and without | |
| 25 | - | // detail for anything else. | |
| 21 | + | // "Cache-Control: no-store". A request the use case refuses is answered with a | |
| 22 | + | // state page that says why, in the words of the use case for the rules the | |
| 23 | + | // owner can fix (a text rule, a moved branch, a full merge request), and | |
| 24 | + | // without detail for anything else. | |
| 26 | 25 | ||
| 27 | 26 | // maxFormBytes bounds the body of a form: a description of 8,000 characters is | |
| 28 | 27 | // at most 32,000 bytes. | |
| @@ -44,8 +43,8 @@ func seeOther(w http.ResponseWriter, r *http.Request, to string) { | |||
| 44 | 43 | http.Redirect(w, r, to, http.StatusSeeOther) | |
| 45 | 44 | } | |
| 46 | 45 | ||
| 47 | - | // mrAction wraps a POST handler: it bounds the API calls, reads the form and | |
| 48 | - | // maps the error of the API to a page. | |
| 46 | + | // mrAction wraps a POST handler: it bounds the use case calls, reads the form | |
| 47 | + | // and maps the error of the use case to a page. | |
| 49 | 48 | func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc { | |
| 50 | 49 | return func(w http.ResponseWriter, r *http.Request) { | |
| 51 | 50 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| @@ -64,37 +63,42 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin | |||
| 64 | 63 | ||
| 65 | 64 | // actionFailed answers a refused action. | |
| 66 | 65 | func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |
| 67 | - | msg := "" | |
| 68 | - | var ce *connect.Error | |
| 69 | - | if errors.As(err, &ce) { | |
| 70 | - | msg = ce.Message() | |
| 71 | - | } | |
| 72 | - | switch code := connect.CodeOf(err); { | |
| 73 | - | case code == connect.CodeNotFound: | |
| 66 | + | // The words of the use case, for the rules the owner can fix. | |
| 67 | + | msg := err.Error() | |
| 68 | + | // The order is that of the API's mapping of the domain errors to codes | |
| 69 | + | // (rpc.codeOf), so that a web action answers what the API would. | |
| 70 | + | switch { | |
| 71 | + | case errors.Is(err, context.Canceled): | |
| 72 | + | // The browser is gone. | |
| 73 | + | case errors.Is(err, domain.ErrNotFound): | |
| 74 | 74 | w.Header().Set("Cache-Control", "no-store") | |
| 75 | 75 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |
| 76 | - | case code == connect.CodeInvalidArgument: | |
| 77 | - | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |
| 78 | - | case code == connect.CodeFailedPrecondition, code == connect.CodeAborted: | |
| 79 | - | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) | |
| 80 | - | case code == connect.CodeAlreadyExists: | |
| 76 | + | case errors.Is(err, domain.ErrAlreadyExists): | |
| 81 | 77 | s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back) | |
| 82 | - | case code == connect.CodeResourceExhausted: | |
| 83 | - | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |
| 84 | - | case code == connect.CodePermissionDenied, code == connect.CodeUnauthenticated: | |
| 78 | + | case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected): | |
| 85 | 79 | s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back) | |
| 86 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 87 | - | // The browser is gone. | |
| 80 | + | case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict): | |
| 81 | + | s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back) | |
| 82 | + | case errors.Is(err, domain.ErrResourceExhausted): | |
| 83 | + | s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back) | |
| 84 | + | case domain.IsInvalid(err): | |
| 85 | + | s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back) | |
| 88 | 86 | default: | |
| 89 | - | id := requestID() | |
| 90 | - | log := zerowrap.FromCtx(ctx) | |
| 91 | - | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |
| 92 | - | w.Header().Set("Cache-Control", "no-store") | |
| 93 | - | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 87 | + | s.internalFailure(ctx, w, r, err) | |
| 94 | 88 | } | |
| 95 | 89 | } | |
| 96 | 90 | ||
| 97 | - | // rule is a message of the API fit to show: the invisible characters marked, | |
| 91 | + | // internalFailure answers a failure of an action that is not the owner's to | |
| 92 | + | // fix: the 500 page with an id, and the cause in the log. | |
| 93 | + | func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 94 | + | id := requestID() | |
| 95 | + | log := zerowrap.FromCtx(ctx) | |
| 96 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed") | |
| 97 | + | w.Header().Set("Cache-Control", "no-store") | |
| 98 | + | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // rule is a message of the use case fit to show: the invisible characters marked, | |
| 98 | 102 | // bounded, and a fallback for an empty one. | |
| 99 | 103 | func rule(msg, fallback string) string { | |
| 100 | 104 | msg = strings.TrimSpace(visibleText(msg)) | |
| @@ -126,69 +130,116 @@ func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, | |||
| 126 | 130 | repo := r.PathValue("repo") | |
| 127 | 131 | source := r.PostFormValue("source") | |
| 128 | 132 | back := repoHref(repo, "compare", source) | |
| 129 | - | res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{ | |
| 130 | - | Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | |
| 131 | - | SourceBranch: source, TargetBranch: r.PostFormValue("target"), | |
| 132 | - | })) | |
| 133 | + | name, err := domain.ParseRepoName(repo) | |
| 134 | + | if err != nil { | |
| 135 | + | return "", back, err | |
| 136 | + | } | |
| 137 | + | mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{ | |
| 138 | + | Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")), | |
| 139 | + | Source: source, Target: r.PostFormValue("target"), | |
| 140 | + | }) | |
| 133 | 141 | if err != nil { | |
| 134 | 142 | return "", back, err | |
| 135 | 143 | } | |
| 136 | - | return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil | |
| 144 | + | return mrHref(repo, mr.Number), back, nil | |
| 137 | 145 | } | |
| 138 | 146 | ||
| 147 | + | // formError is a form the server refuses before asking a use case: it matches | |
| 148 | + | // domain.ErrInvalid and says just what is wrong. | |
| 149 | + | type formError string | |
| 150 | + | ||
| 151 | + | func (e formError) Error() string { return string(e) } | |
| 152 | + | ||
| 153 | + | func (formError) Unwrap() error { return domain.ErrInvalid } | |
| 154 | + | ||
| 139 | 155 | // mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or | |
| 140 | 156 | // one on a line (path, line and side). | |
| 141 | 157 | func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) { | |
| 142 | 158 | repo := r.PathValue("repo") | |
| 143 | 159 | n, err := number(r) | |
| 144 | 160 | if err != nil { | |
| 145 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | |
| 161 | + | return "", "", err | |
| 146 | 162 | } | |
| 147 | 163 | back := mrHref(repo, n) | |
| 148 | - | req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} | |
| 149 | - | if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { | |
| 164 | + | params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")} | |
| 165 | + | if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" { | |
| 150 | 166 | line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32) | |
| 151 | 167 | if err != nil { | |
| 152 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("line is not a number")) | |
| 168 | + | return "", back, formError("line is not a number") | |
| 153 | 169 | } | |
| 154 | - | req.Line = int32(line) | |
| 170 | + | params.Line = int(line) | |
| 155 | 171 | switch r.PostFormValue("side") { | |
| 156 | 172 | case "new": | |
| 157 | - | req.Side = pb.DiffSide_DIFF_SIDE_NEW | |
| 173 | + | params.Side = domain.SideNew | |
| 158 | 174 | case "old": | |
| 159 | - | req.Side = pb.DiffSide_DIFF_SIDE_OLD | |
| 175 | + | params.Side = domain.SideOld | |
| 160 | 176 | default: | |
| 161 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("side must be new or old")) | |
| 177 | + | return "", back, formError("side must be new or old") | |
| 162 | 178 | } | |
| 163 | 179 | } | |
| 164 | - | res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req)) | |
| 180 | + | name, err := domain.ParseRepoName(repo) | |
| 165 | 181 | if err != nil { | |
| 166 | 182 | return "", back, err | |
| 167 | 183 | } | |
| 168 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | |
| 184 | + | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) | |
| 185 | + | if err != nil { | |
| 186 | + | return "", back, err | |
| 187 | + | } | |
| 188 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |
| 169 | 189 | } | |
| 170 | 190 | ||
| 171 | 191 | // mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve. | |
| 172 | 192 | // | |
| 173 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API | |
| 193 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case | |
| 174 | 194 | func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) { | |
| 175 | 195 | repo := r.PathValue("repo") | |
| 176 | 196 | n, err := number(r) | |
| 177 | 197 | if err != nil { | |
| 178 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | |
| 198 | + | return "", "", err | |
| 179 | 199 | } | |
| 180 | 200 | back := mrHref(repo, n) | |
| 181 | 201 | resolved, err := formBool(r.PostFormValue("resolved")) | |
| 182 | 202 | if err != nil { | |
| 183 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err)) | |
| 203 | + | return "", back, formError("resolved: " + err.Error()) | |
| 184 | 204 | } | |
| 185 | - | res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{ | |
| 186 | - | Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved, | |
| 187 | - | })) | |
| 205 | + | name, err := domain.ParseRepoName(repo) | |
| 188 | 206 | if err != nil { | |
| 189 | 207 | return "", back, err | |
| 190 | 208 | } | |
| 191 | - | return back + "#c" + res.Msg.GetComment().GetId(), back, nil | |
| 209 | + | // An id this server never issued is 0: the use case answers not found, | |
| 210 | + | // after the repository and the scope were checked, as for any unknown | |
| 211 | + | // comment. | |
| 212 | + | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | |
| 213 | + | if err != nil { | |
| 214 | + | return "", back, err | |
| 215 | + | } | |
| 216 | + | return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // commentID parses the id of a comment in a path: a positive decimal number, | |
| 220 | + | // written without sign or leading zeros. Anything else is 0, which names no | |
| 221 | + | // comment. | |
| 222 | + | func commentID(s string) int64 { | |
| 223 | + | n, err := strconv.ParseInt(s, 10, 64) | |
| 224 | + | if err != nil || n < 1 || strconv.FormatInt(n, 10) != s { | |
| 225 | + | return 0 | |
| 226 | + | } | |
| 227 | + | return n | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // headID parses a commit id of a form; the empty string is the zero id. | |
| 231 | + | // | |
| 232 | + | //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 | |
| 233 | + | func headID(s string) (domain.ObjectID, error) { | |
| 234 | + | if s == "" { | |
| 235 | + | return domain.ObjectID{}, nil | |
| 236 | + | } | |
| 237 | + | var v domain.Violations | |
| 238 | + | id, err := domain.ParseObjectID(s) | |
| 239 | + | if err != nil { | |
| 240 | + | v.Add("head_id", "is not a full commit id") | |
| 241 | + | } | |
| 242 | + | return id, v.Err() | |
| 192 | 243 | } | |
| 193 | 244 | ||
| 194 | 245 | // mrClose is POST /<repo>/merge-requests/<n>/close. | |
| @@ -196,10 +247,14 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, | |||
| 196 | 247 | repo := r.PathValue("repo") | |
| 197 | 248 | n, err := number(r) | |
| 198 | 249 | if err != nil { | |
| 199 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | |
| 250 | + | return "", "", err | |
| 200 | 251 | } | |
| 201 | 252 | back := mrHref(repo, n) | |
| 202 | - | if _, err := s.cfg.MergeRequests.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: repo, Number: n})); err != nil { | |
| 253 | + | name, err := domain.ParseRepoName(repo) | |
| 254 | + | if err != nil { | |
| 255 | + | return "", back, err | |
| 256 | + | } | |
| 257 | + | if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil { | |
| 203 | 258 | return "", back, err | |
| 204 | 259 | } | |
| 205 | 260 | return back, back, nil | |
| @@ -213,22 +268,29 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, | |||
| 213 | 268 | repo := r.PathValue("repo") | |
| 214 | 269 | n, err := number(r) | |
| 215 | 270 | if err != nil { | |
| 216 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | |
| 271 | + | return "", "", err | |
| 217 | 272 | } | |
| 218 | 273 | back := mrHref(repo, n) | |
| 219 | - | var mode pb.MergeMode | |
| 274 | + | var mode domain.MergeMode | |
| 220 | 275 | switch r.PostFormValue("mode") { | |
| 221 | 276 | case "fast-forward": | |
| 222 | - | mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD | |
| 277 | + | mode = domain.MergeModeFastForward | |
| 223 | 278 | case "squash": | |
| 224 | - | mode = pb.MergeMode_MERGE_MODE_SQUASH | |
| 279 | + | mode = domain.MergeModeSquash | |
| 225 | 280 | default: | |
| 226 | - | return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("mode must be fast-forward or squash")) | |
| 281 | + | return "", back, formError("mode must be fast-forward or squash") | |
| 227 | 282 | } | |
| 228 | - | if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{ | |
| 229 | - | Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"), | |
| 230 | - | DeleteSourceBranch: r.PostFormValue("delete_branch") == "1", | |
| 231 | - | })); err != nil { | |
| 283 | + | name, err := domain.ParseRepoName(repo) | |
| 284 | + | if err != nil { | |
| 285 | + | return "", back, err | |
| 286 | + | } | |
| 287 | + | head, err := headID(r.PostFormValue("head_id")) | |
| 288 | + | if err != nil { | |
| 289 | + | return "", back, err | |
| 290 | + | } | |
| 291 | + | if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{ | |
| 292 | + | Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1", | |
| 293 | + | }); err != nil { | |
| 232 | 294 | return "", back, err | |
| 233 | 295 | } | |
| 234 | 296 | return back, back, nil | |
| @@ -240,12 +302,18 @@ func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, e | |||
| 240 | 302 | repo := r.PathValue("repo") | |
| 241 | 303 | n, err := number(r) | |
| 242 | 304 | if err != nil { | |
| 243 | - | return "", "", connect.NewError(connect.CodeNotFound, err) | |
| 305 | + | return "", "", err | |
| 244 | 306 | } | |
| 245 | 307 | back := mrHref(repo, n) | |
| 246 | - | if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{ | |
| 247 | - | Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"), | |
| 248 | - | })); err != nil { | |
| 308 | + | name, err := domain.ParseRepoName(repo) | |
| 309 | + | if err != nil { | |
| 310 | + | return "", back, err | |
| 311 | + | } | |
| 312 | + | head, err := headID(r.PostFormValue("head_id")) | |
| 313 | + | if err != nil { | |
| 314 | + | return "", back, err | |
| 315 | + | } | |
| 316 | + | if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil { | |
| 249 | 317 | return "", back, err | |
| 250 | 318 | } | |
| 251 | 319 | return back, back, nil | |
| @@ -263,7 +331,7 @@ func formBool(s string) (bool, error) { | |||
| 263 | 331 | } | |
| 264 | 332 | ||
| 265 | 333 | // normalizeNewlines turns the CRLF of a browser textarea into LF, the line | |
| 266 | - | // break the API stores. | |
| 334 | + | // break the use case stores. | |
| 267 | 335 | func normalizeNewlines(s string) string { | |
| 268 | 336 | return strings.ReplaceAll(s, "\r\n", "\n") | |
| 269 | 337 | } | |
M internal/adapters/web/mr_templ.go +202 -194 generated file: collapsed
M internal/adapters/web/overlays.templ +4 -10
| @@ -37,8 +37,8 @@ templ OverlayRefs(l Layout) { | |||
| 37 | 37 | type="search" | |
| 38 | 38 | placeholder="Switch branch or tag…" | |
| 39 | 39 | autocomplete="off" | |
| 40 | - | data-pick | |
| 41 | - | hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))" | |
| 40 | + | data-pick="#panel-refs li" | |
| 41 | + | hx-on:input="ermite.filter(this)" | |
| 42 | 42 | /> | |
| 43 | 43 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | |
| 44 | 44 | </div> | |
| @@ -96,14 +96,8 @@ templ OverlayCmd(items []CmdItem) { | |||
| 96 | 96 | autocomplete="off" | |
| 97 | 97 | spellcheck="false" | |
| 98 | 98 | aria-controls="cmd-list" | |
| 99 | - | hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 100 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 101 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" | |
| 102 | - | hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 103 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 104 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 105 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 106 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }" | |
| 99 | + | hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" | |
| 100 | + | hx-on:keydown="ermite.cmdKey(event)" | |
| 107 | 101 | /> | |
| 108 | 102 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd></button> | |
| 109 | 103 | </div> | |
M internal/adapters/web/overlays_templ.go +6 -6 generated file: collapsed
M internal/adapters/web/pages.templ +4 -5
| @@ -10,10 +10,10 @@ templ Home(l Layout, p HomePage) { | |||
| 10 | 10 | <input | |
| 11 | 11 | id="home-filter" | |
| 12 | 12 | type="search" | |
| 13 | - | data-filter | |
| 13 | + | data-filter="#repo-rows > tr" | |
| 14 | 14 | placeholder="Filter repositories…" | |
| 15 | 15 | autocomplete="off" | |
| 16 | - | hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')" | |
| 16 | + | hx-on:input="ermite.filter(this)" | |
| 17 | 17 | /> | |
| 18 | 18 | </div> | |
| 19 | 19 | <table class="list home" aria-label="Repositories"> | |
| @@ -94,11 +94,10 @@ templ Refs(l Layout, p RefsPage) { | |||
| 94 | 94 | <input | |
| 95 | 95 | id="ref-filter" | |
| 96 | 96 | type="search" | |
| 97 | - | data-filter | |
| 97 | + | data-filter="#ref-rows > tr" | |
| 98 | 98 | placeholder="Filter branches and tags…" | |
| 99 | 99 | autocomplete="off" | |
| 100 | - | hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 101 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')" | |
| 100 | + | hx-on:input="ermite.filter(this)" | |
| 102 | 101 | /> | |
| 103 | 102 | </div> | |
| 104 | 103 | <table class="list refs" aria-label="Branches and tags"> | |
M internal/adapters/web/pages_templ.go +22 -22 generated file: collapsed
M internal/adapters/web/pathcache_test.go +9 -0
| @@ -31,6 +31,7 @@ func (c *pathCache) has(id string) bool { | |||
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | |
| 34 | + | t.Parallel() | |
| 34 | 35 | c := newPathCache(100, 1) | |
| 35 | 36 | blob := func(n int) []byte { return make([]byte, n) } | |
| 36 | 37 | c.store("a", blob(25)) | |
| @@ -57,6 +58,7 @@ func TestPathCacheEvictsLeastRecentlyUsed(t *testing.T) { | |||
| 57 | 58 | // A list over a quarter of the budget is not kept, since it would push out the | |
| 58 | 59 | // others; one at the limit is. | |
| 59 | 60 | func TestPathCacheSkipsOversizedEntries(t *testing.T) { | |
| 61 | + | t.Parallel() | |
| 60 | 62 | c := newPathCache(100, 1) | |
| 61 | 63 | var builds atomic.Int32 | |
| 62 | 64 | big := func(n int) func() ([]byte, error) { | |
| @@ -80,6 +82,7 @@ func TestPathCacheSkipsOversizedEntries(t *testing.T) { | |||
| 80 | 82 | } | |
| 81 | 83 | ||
| 82 | 84 | func TestPathCacheConcurrent(t *testing.T) { | |
| 85 | + | t.Parallel() | |
| 83 | 86 | c := newPathCache(1000, 1) | |
| 84 | 87 | var wg sync.WaitGroup | |
| 85 | 88 | for g := range 8 { | |
| @@ -122,6 +125,7 @@ func building(c *pathCache, n int) bool { | |||
| 122 | 125 | // Concurrent misses for one commit build once; a failure is shared and not | |
| 123 | 126 | // stored; a waiter that gives up leaves the build running. | |
| 124 | 127 | func TestPathCacheLoadBuildsOnce(t *testing.T) { | |
| 128 | + | t.Parallel() | |
| 125 | 129 | c := newPathCache(1000, 4) | |
| 126 | 130 | var builds atomic.Int32 | |
| 127 | 131 | release := make(chan struct{}) | |
| @@ -172,6 +176,7 @@ func TestPathCacheLoadBuildsOnce(t *testing.T) { | |||
| 172 | 176 | // commit waits for the running build, and leaves with its ctx if it is | |
| 173 | 177 | // cancelled first. The slot is the build's, not the caller's. | |
| 174 | 178 | func TestPathCacheLoadBoundsBuilds(t *testing.T) { | |
| 179 | + | t.Parallel() | |
| 175 | 180 | c := newPathCache(1000, 1) | |
| 176 | 181 | started, release := make(chan struct{}), make(chan struct{}) | |
| 177 | 182 | first := make(chan error, 1) | |
| @@ -218,6 +223,7 @@ func TestPathCacheLoadBoundsBuilds(t *testing.T) { | |||
| 218 | 223 | // The slot of a build is kept after its callers leave, and given back when it | |
| 219 | 224 | // ends, whether it fails or panics. | |
| 220 | 225 | func TestPathCacheSlotOutlivesCallers(t *testing.T) { | |
| 226 | + | t.Parallel() | |
| 221 | 227 | c := newPathCache(1000, 1) | |
| 222 | 228 | started, release := make(chan struct{}), make(chan struct{}) | |
| 223 | 229 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -240,6 +246,7 @@ func TestPathCacheSlotOutlivesCallers(t *testing.T) { | |||
| 240 | 246 | // A build that panics is handed to its waiters as an error, leaves nothing | |
| 241 | 247 | // in flight or cached, and does not take the process down. | |
| 242 | 248 | func TestPathCacheLoadPanic(t *testing.T) { | |
| 249 | + | t.Parallel() | |
| 243 | 250 | c := newPathCache(1000, 4) | |
| 244 | 251 | started, release := make(chan struct{}), make(chan struct{}) | |
| 245 | 252 | var once sync.Once | |
| @@ -272,6 +279,7 @@ func TestPathCacheLoadPanic(t *testing.T) { | |||
| 272 | 279 | // The caller that starts a build is released with its context too, and the | |
| 273 | 280 | // build goes on for the others. | |
| 274 | 281 | func TestPathCacheLoadStarterLeaves(t *testing.T) { | |
| 282 | + | t.Parallel() | |
| 275 | 283 | c := newPathCache(1000, 4) | |
| 276 | 284 | release := make(chan struct{}) | |
| 277 | 285 | build := func() ([]byte, error) { <-release; return []byte("list"), nil } | |
| @@ -294,6 +302,7 @@ func TestPathCacheLoadStarterLeaves(t *testing.T) { | |||
| 294 | 302 | // A failure belongs to the repository that built it: another repository | |
| 295 | 303 | // asking for the same commit builds its own. | |
| 296 | 304 | func TestPathCacheLoadFailureStaysInRepo(t *testing.T) { | |
| 305 | + | t.Parallel() | |
| 297 | 306 | c := newPathCache(1000, 4) | |
| 298 | 307 | started, release := make(chan struct{}), make(chan struct{}) | |
| 299 | 308 | boom := errors.New("boom") | |
M internal/adapters/web/paths.go +19 -44
| @@ -11,11 +11,8 @@ import ( | |||
| 11 | 11 | "io" | |
| 12 | 12 | "net/http" | |
| 13 | 13 | "strconv" | |
| 14 | - | "strings" | |
| 15 | 14 | ||
| 16 | - | "connectrpc.com/connect" | |
| 17 | - | ||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | 16 | ) | |
| 20 | 17 | ||
| 21 | 18 | // errBuildPanicked is what the waiters of a path list get when its build | |
| @@ -31,34 +28,41 @@ type pathList struct { | |||
| 31 | 28 | // paths is GET /{repo}/paths/{sha}: the file paths of a commit as JSON, for | |
| 32 | 29 | // the file finder (files.js). The commit must be a full id; the answer never | |
| 33 | 30 | // changes for it, but who may read it does, so every request is authorized | |
| 34 | - | // first (ResolveRevision runs the access rule) and only then served from the | |
| 31 | + | // first (ResolveRevision runs the access rule, as the caller of the request) and only then served from the | |
| 35 | 32 | // cache of lists. Caching follows raw: a visitor revalidates every time, with | |
| 36 | 33 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | |
| 37 | 34 | // waits for a build); the owner's copy is private and never stored. The ETag | |
| 38 | 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 | |
| 39 | 38 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | |
| 40 | 39 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 41 | 40 | defer cancel() | |
| 42 | - | repo, sha := r.PathValue("repo"), r.PathValue("sha") | |
| 41 | + | sha := r.PathValue("sha") | |
| 43 | 42 | if !isFullID(sha) { | |
| 44 | 43 | s.fail(ctx, w, r, errNotFound) | |
| 45 | 44 | return | |
| 46 | 45 | } | |
| 47 | - | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: sha})) | |
| 46 | + | repo, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 47 | + | if err != nil { | |
| 48 | + | s.fail(ctx, w, r, err) | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | res, err := s.resolveRevision(ctx, repo, sha) | |
| 48 | 52 | if err != nil { | |
| 49 | 53 | s.fail(ctx, w, r, err) | |
| 50 | 54 | return | |
| 51 | 55 | } | |
| 52 | 56 | // A ref can be named like a commit id: only the commit itself is served, | |
| 53 | 57 | // since the cache is keyed by it. | |
| 54 | - | if res.Msg.GetCommitId() != sha { | |
| 58 | + | if res.CommitID.String() != sha { | |
| 55 | 59 | s.fail(ctx, w, r, errNotFound) | |
| 56 | 60 | return | |
| 57 | 61 | } | |
| 58 | 62 | ||
| 59 | 63 | // The validator names the variant that would be served: the gzip bytes | |
| 60 | 64 | // and the plain JSON are different representations. | |
| 61 | - | gz := acceptsGzip(r.Header.Get("Accept-Encoding")) | |
| 65 | + | gz := acceptsGzip(r.Header) | |
| 62 | 66 | etag := `"paths-` + sha + `"` | |
| 63 | 67 | if gz { | |
| 64 | 68 | etag = `"paths-` + sha + `-gz"` | |
| @@ -77,7 +81,7 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | |||
| 77 | 81 | w.WriteHeader(http.StatusNotModified) | |
| 78 | 82 | return | |
| 79 | 83 | } | |
| 80 | - | body, err := s.pathCache.load(ctx, repo, sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) }) | |
| 84 | + | body, err := s.pathCache.load(ctx, repo.String(), sha, func() ([]byte, error) { return s.buildPaths(ctx, repo, sha) }) | |
| 81 | 85 | if err != nil { | |
| 82 | 86 | s.fail(ctx, w, r, err) | |
| 83 | 87 | return | |
| @@ -122,17 +126,17 @@ func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) | |||
| 122 | 126 | // buildPaths lists the paths of a commit and returns them as gzipped JSON. | |
| 123 | 127 | // The build is shared by the requests that wait for it, so it does not stop | |
| 124 | 128 | // when the request that started it ends. | |
| 125 | - | func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, error) { | |
| 129 | + | func (s *Server) buildPaths(ctx context.Context, repo domain.RepoName, sha string) ([]byte, error) { | |
| 126 | 130 | ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), requestTimeout) | |
| 127 | 131 | defer cancel() | |
| 128 | - | res, err := s.cfg.Git.ListPaths(ctx, connect.NewRequest(&pb.ListPathsRequest{Repo: repo, Revision: sha})) | |
| 132 | + | list, err := s.listPaths(ctx, repo, sha) | |
| 129 | 133 | if err != nil { | |
| 130 | 134 | return nil, err | |
| 131 | 135 | } | |
| 132 | - | if res.Msg.GetCommitId() != sha { | |
| 136 | + | if list.CommitID.String() != sha { | |
| 133 | 137 | return nil, errNotFound | |
| 134 | 138 | } | |
| 135 | - | paths := res.Msg.GetPaths() | |
| 139 | + | paths := list.Paths | |
| 136 | 140 | if paths == nil { | |
| 137 | 141 | paths = []string{} | |
| 138 | 142 | } | |
| @@ -143,7 +147,7 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro | |||
| 143 | 147 | } | |
| 144 | 148 | enc := json.NewEncoder(zw) | |
| 145 | 149 | enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are | |
| 146 | - | if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil { | |
| 150 | + | if err := enc.Encode(pathList{Paths: paths, Truncated: list.Truncated}); err != nil { | |
| 147 | 151 | return nil, err | |
| 148 | 152 | } | |
| 149 | 153 | if err := zw.Close(); err != nil { | |
| @@ -156,32 +160,3 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro | |||
| 156 | 160 | copy(body, buf.Bytes()) | |
| 157 | 161 | return body, nil | |
| 158 | 162 | } | |
| 159 | - | ||
| 160 | - | // acceptsGzip reports whether an Accept-Encoding header allows gzip. | |
| 161 | - | func acceptsGzip(h string) bool { | |
| 162 | - | for f := range strings.SplitSeq(h, ",") { | |
| 163 | - | name, params, _ := strings.Cut(strings.TrimSpace(f), ";") | |
| 164 | - | if !strings.EqualFold(strings.TrimSpace(name), "gzip") { | |
| 165 | - | continue | |
| 166 | - | } | |
| 167 | - | if q, ok := strings.CutPrefix(strings.ReplaceAll(strings.TrimSpace(params), " ", ""), "q="); ok { | |
| 168 | - | if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 { | |
| 169 | - | return false | |
| 170 | - | } | |
| 171 | - | } | |
| 172 | - | return true | |
| 173 | - | } | |
| 174 | - | return false | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // etagMatches reports whether an If-None-Match header lists etag (weak | |
| 178 | - | // comparison, or "*"). | |
| 179 | - | func etagMatches(header, etag string) bool { | |
| 180 | - | for f := range strings.SplitSeq(header, ",") { | |
| 181 | - | f = strings.TrimSpace(f) | |
| 182 | - | if f == "*" || strings.TrimPrefix(f, "W/") == etag { | |
| 183 | - | return true | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | return false | |
| 187 | - | } | |
M internal/adapters/web/paths_test.go +159 -116
| @@ -6,168 +6,211 @@ import ( | |||
| 6 | 6 | "bytes" | |
| 7 | 7 | "compress/gzip" | |
| 8 | 8 | "context" | |
| 9 | + | "errors" | |
| 9 | 10 | "io" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "net/http/httptest" | |
| 12 | 13 | "strings" | |
| 13 | - | "sync/atomic" | |
| 14 | 14 | "testing" | |
| 15 | 15 | ||
| 16 | - | "connectrpc.com/connect" | |
| 17 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 18 | 18 | "github.com/stretchr/testify/require" | |
| 19 | 19 | ||
| 20 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | 23 | ) | |
| 23 | 24 | ||
| 24 | - | // pathsGit is a Git client that resolves any revision to itself and counts | |
| 25 | - | // the lists it builds. | |
| 26 | - | type pathsGit struct { | |
| 27 | - | ermiteconnect.GitServiceClient | |
| 28 | - | lists atomic.Int32 | |
| 29 | - | paths []string // the list; a.go and b/c.go when nil | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | |
| 33 | - | return connect.NewResponse(&pb.ResolveRevisionResponse{CommitId: req.Msg.GetRevision()}), nil | |
| 25 | + | // pathsSHA is the commit the path list tests ask for. | |
| 26 | + | var pathsSHA = strings.Repeat("a", 40) | |
| 27 | + | ||
| 28 | + | // pathsBrowse resolves any full id to itself and lists a.go and b/c.go (or | |
| 29 | + | // paths) for the commit, as the anonymous visitor. lists is how many times | |
| 30 | + | // the list may be built: 0 means ListPaths must not be called. | |
| 31 | + | func pathsBrowse(t testing.TB, lists int, paths []string) *mocks.BrowseService { | |
| 32 | + | t.Helper() | |
| 33 | + | browse := mocks.NewBrowseService(t) | |
| 34 | + | browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn( | |
| 35 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 36 | + | return domain.ResolvedRevision{CommitID: objectID(t, rev.String())}, nil | |
| 37 | + | }).Maybe() | |
| 38 | + | if lists > 0 { | |
| 39 | + | if paths == nil { | |
| 40 | + | paths = []string{"a.go", "b/c.go"} | |
| 41 | + | } | |
| 42 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return( | |
| 43 | + | domain.PathList{CommitID: objectID(t, pathsSHA), Paths: paths}, nil).Times(lists) | |
| 44 | + | } | |
| 45 | + | return browse | |
| 34 | 46 | } | |
| 35 | 47 | ||
| 36 | - | func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) { | |
| 37 | - | g.lists.Add(1) | |
| 38 | - | paths := g.paths | |
| 39 | - | if paths == nil { | |
| 40 | - | paths = []string{"a.go", "b/c.go"} | |
| 48 | + | func getPaths(srv *Server, sha string, hdr map[string]string) *httptest.ResponseRecorder { | |
| 49 | + | req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil) | |
| 50 | + | for k, v := range hdr { | |
| 51 | + | req.Header.Set(k, v) | |
| 41 | 52 | } | |
| 42 | - | return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil | |
| 53 | + | rec := httptest.NewRecorder() | |
| 54 | + | srv.ServeHTTP(rec, req) | |
| 55 | + | return rec | |
| 43 | 56 | } | |
| 44 | 57 | ||
| 45 | 58 | // A revalidation is answered after the access check and before the list is | |
| 46 | - | // loaded: it never builds. Each variant has its own validator. | |
| 59 | + | // loaded: it never builds (the mock has no ListPaths to answer). Each variant | |
| 60 | + | // has its own validator. | |
| 47 | 61 | func TestPathsRevalidationDoesNotBuild(t *testing.T) { | |
| 48 | - | git := &pathsGit{} | |
| 49 | - | srv, err := New(Config{ | |
| 50 | - | Repos: failing{}, Git: git, | |
| 51 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 52 | - | }) | |
| 53 | - | require.NoError(t, err) | |
| 54 | - | sha := strings.Repeat("a", 40) | |
| 55 | - | get := func(hdr map[string]string) *httptest.ResponseRecorder { | |
| 56 | - | req := httptest.NewRequest(http.MethodGet, "/r/paths/"+sha, nil) | |
| 57 | - | for k, v := range hdr { | |
| 58 | - | req.Header.Set(k, v) | |
| 59 | - | } | |
| 60 | - | rec := httptest.NewRecorder() | |
| 61 | - | srv.ServeHTTP(rec, req) | |
| 62 | - | return rec | |
| 63 | - | } | |
| 64 | - | gzTag, plainTag := `"paths-`+sha+`-gz"`, `"paths-`+sha+`"` | |
| 65 | - | ||
| 66 | - | for _, tt := range []struct{ name, ae, inm string }{ | |
| 62 | + | t.Parallel() | |
| 63 | + | gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"` | |
| 64 | + | tests := []struct{ name, ae, inm string }{ | |
| 67 | 65 | {"gzip", "gzip", gzTag}, | |
| 68 | 66 | {"identity", "", plainTag}, | |
| 69 | 67 | {"weak", "gzip", "W/" + gzTag}, | |
| 70 | 68 | {"any", "", "*"}, | |
| 71 | - | } { | |
| 72 | - | rec := get(map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm}) | |
| 73 | - | assert.Equal(t, http.StatusNotModified, rec.Code, tt.name) | |
| 74 | - | assert.Empty(t, rec.Body.String(), tt.name) | |
| 75 | - | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"), tt.name) | |
| 76 | - | assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary"), tt.name) | |
| 77 | - | assert.NotEmpty(t, rec.Header().Get("ETag"), tt.name) | |
| 78 | 69 | } | |
| 79 | - | assert.Zero(t, git.lists.Load(), "a 304 never builds") | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | t.Parallel() | |
| 73 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 0, nil)}) | |
| 74 | + | rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": tt.ae, "If-None-Match": tt.inm}) | |
| 75 | + | assert.Equal(t, http.StatusNotModified, rec.Code) | |
| 76 | + | assert.Empty(t, rec.Body.String()) | |
| 77 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 78 | + | assert.Equal(t, "Accept-Encoding", rec.Header().Get("Vary")) | |
| 79 | + | assert.NotEmpty(t, rec.Header().Get("ETag")) | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // The list is built once, then served from the cache, in the variant the | |
| 85 | + | // client accepts; the validator of the other variant does not match. | |
| 86 | + | func TestPathsServesAndCaches(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | gzTag, plainTag := `"paths-`+pathsSHA+`-gz"`, `"paths-`+pathsSHA+`"` | |
| 89 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: pathsBrowse(t, 1, nil)}) | |
| 80 | 90 | ||
| 81 | - | // The validator of the other variant does not match. | |
| 82 | - | assert.Equal(t, http.StatusOK, get(map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code) | |
| 83 | - | assert.Equal(t, http.StatusOK, get(map[string]string{"If-None-Match": gzTag}).Code) | |
| 84 | - | assert.EqualValues(t, 1, git.lists.Load(), "built once, then cached") | |
| 91 | + | assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip", "If-None-Match": plainTag}).Code) | |
| 92 | + | assert.Equal(t, http.StatusOK, getPaths(srv, pathsSHA, map[string]string{"If-None-Match": gzTag}).Code) | |
| 85 | 93 | ||
| 86 | - | rec := get(map[string]string{"Accept-Encoding": "gzip"}) | |
| 94 | + | rec := getPaths(srv, pathsSHA, map[string]string{"Accept-Encoding": "gzip"}) | |
| 87 | 95 | assert.Equal(t, gzTag, rec.Header().Get("ETag")) | |
| 88 | 96 | assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding")) | |
| 97 | + | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 89 | 98 | zr, err := gzip.NewReader(rec.Body) | |
| 90 | 99 | require.NoError(t, err) | |
| 91 | 100 | out, err := io.ReadAll(zr) | |
| 92 | 101 | require.NoError(t, err) | |
| 93 | 102 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, string(out)) | |
| 94 | 103 | ||
| 95 | - | rec = get(nil) | |
| 104 | + | rec = getPaths(srv, pathsSHA, nil) | |
| 96 | 105 | assert.Equal(t, plainTag, rec.Header().Get("ETag")) | |
| 97 | 106 | assert.Empty(t, rec.Header().Get("Content-Encoding")) | |
| 98 | 107 | assert.JSONEq(t, `{"paths":["a.go","b/c.go"],"truncated":false}`, rec.Body.String()) | |
| 99 | 108 | } | |
| 100 | 109 | ||
| 101 | - | // The cached list holds no spare capacity. | |
| 102 | - | func TestBuildPathsCachesATightBody(t *testing.T) { | |
| 103 | - | srv, err := New(Config{Repos: failing{}, Git: &pathsGit{}, PublicURL: "https://forge.example"}) | |
| 104 | - | require.NoError(t, err) | |
| 105 | - | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | |
| 106 | - | require.NoError(t, err) | |
| 107 | - | assert.Equal(t, len(body), cap(body)) | |
| 110 | + | // The commit is checked as the caller of the request, and only a full id that | |
| 111 | + | // resolves to itself is served. | |
| 112 | + | func TestPathsRefusals(t *testing.T) { | |
| 113 | + | t.Parallel() | |
| 114 | + | other := strings.Repeat("b", 40) | |
| 115 | + | tests := []struct { | |
| 116 | + | name string | |
| 117 | + | sha string | |
| 118 | + | resolve func() (domain.ResolvedRevision, error) | |
| 119 | + | want int | |
| 120 | + | }{ | |
| 121 | + | {"not a full id", "main", nil, http.StatusNotFound}, | |
| 122 | + | {"abbreviated id", "aaaaaaa", nil, http.StatusNotFound}, | |
| 123 | + | {"commit not found or refused to the caller", pathsSHA, func() (domain.ResolvedRevision, error) { return domain.ResolvedRevision{}, domain.ErrNotFound }, http.StatusNotFound}, | |
| 124 | + | {"a ref named like a commit", pathsSHA, func() (domain.ResolvedRevision, error) { | |
| 125 | + | return domain.ResolvedRevision{CommitID: objectID(t, other)}, nil | |
| 126 | + | }, http.StatusNotFound}, | |
| 127 | + | {"backend failure", pathsSHA, func() (domain.ResolvedRevision, error) { | |
| 128 | + | return domain.ResolvedRevision{}, errors.New("boom") | |
| 129 | + | }, http.StatusInternalServerError}, | |
| 130 | + | } | |
| 131 | + | for _, tt := range tests { | |
| 132 | + | t.Run(tt.name, func(t *testing.T) { | |
| 133 | + | t.Parallel() | |
| 134 | + | browse := mocks.NewBrowseService(t) | |
| 135 | + | if tt.resolve != nil { | |
| 136 | + | browse.EXPECT().ResolveRevision(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).RunAndReturn( | |
| 137 | + | func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error) { | |
| 138 | + | return tt.resolve() | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |
| 142 | + | assert.Equal(t, tt.want, getPaths(srv, tt.sha, nil).Code) | |
| 143 | + | }) | |
| 144 | + | } | |
| 108 | 145 | } | |
| 109 | 146 | ||
| 110 | - | func TestAcceptsGzip(t *testing.T) { | |
| 111 | - | for _, tt := range []struct { | |
| 112 | - | header string | |
| 113 | - | want bool | |
| 147 | + | // The list is built as the caller of the request: a failure of the use case | |
| 148 | + | // is the answer. | |
| 149 | + | func TestPathsListFailure(t *testing.T) { | |
| 150 | + | t.Parallel() | |
| 151 | + | tests := []struct { | |
| 152 | + | name string | |
| 153 | + | err error | |
| 154 | + | want int | |
| 114 | 155 | }{ | |
| 115 | - | {"", false}, | |
| 116 | - | {"gzip", true}, | |
| 117 | - | {"GZip", true}, | |
| 118 | - | {"deflate, gzip", true}, | |
| 119 | - | {"deflate, br", false}, | |
| 120 | - | {"gzip;q=0", false}, | |
| 121 | - | {"gzip; q=0", false}, | |
| 122 | - | {"gzip;q=0.0", false}, | |
| 123 | - | {"gzip ; q = 0", false}, | |
| 124 | - | {"gzip;q=0.5", true}, | |
| 125 | - | {"gzip;q=1", true}, | |
| 126 | - | {"identity, gzip;q=0", false}, | |
| 127 | - | {"gzip;q=0, deflate", false}, | |
| 128 | - | {"gzip;q=bad", true}, | |
| 129 | - | {"x-gzip", false}, | |
| 130 | - | // A wildcard is not read: the finder asks for gzip by name, and the | |
| 131 | - | // plain body is always right. | |
| 132 | - | {"*", false}, | |
| 133 | - | } { | |
| 134 | - | assert.Equal(t, tt.want, acceptsGzip(tt.header), "%q", tt.header) | |
| 156 | + | {"not found", domain.ErrNotFound, http.StatusNotFound}, | |
| 157 | + | {"other", errors.New("boom"), http.StatusInternalServerError}, | |
| 158 | + | } | |
| 159 | + | for _, tt := range tests { | |
| 160 | + | t.Run(tt.name, func(t *testing.T) { | |
| 161 | + | t.Parallel() | |
| 162 | + | browse := pathsBrowse(t, 0, nil) | |
| 163 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(domain.PathList{}, tt.err) | |
| 164 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |
| 165 | + | assert.Equal(t, tt.want, getPaths(srv, pathsSHA, nil).Code) | |
| 166 | + | }) | |
| 135 | 167 | } | |
| 136 | 168 | } | |
| 137 | 169 | ||
| 138 | - | func TestEtagMatches(t *testing.T) { | |
| 139 | - | const tag = `"paths-x"` | |
| 140 | - | for _, tt := range []struct { | |
| 141 | - | header string | |
| 142 | - | want bool | |
| 170 | + | // The cached list holds no spare capacity; the truncation is reported; a path | |
| 171 | + | // is not HTML, so "<", ">" and "&" are not escaped in the JSON. | |
| 172 | + | func TestBuildPaths(t *testing.T) { | |
| 173 | + | t.Parallel() | |
| 174 | + | tests := []struct { | |
| 175 | + | name string | |
| 176 | + | paths []string | |
| 177 | + | truncated bool | |
| 178 | + | want string | |
| 143 | 179 | }{ | |
| 144 | - | {"", false}, | |
| 145 | - | {tag, true}, | |
| 146 | - | {"W/" + tag, true}, | |
| 147 | - | {`"other"`, false}, | |
| 148 | - | {`"a", ` + tag, true}, | |
| 149 | - | {`"a",` + tag + `,"b"`, true}, | |
| 150 | - | {`"a", W/` + tag, true}, | |
| 151 | - | {`"a", "b"`, false}, | |
| 152 | - | {"*", true}, | |
| 153 | - | {`"a", *`, true}, | |
| 154 | - | {`"paths-x-gz"`, false}, | |
| 155 | - | {`w/` + tag, false}, | |
| 156 | - | } { | |
| 157 | - | assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header) | |
| 180 | + | {"plain", []string{"a.go", "b/c.go"}, false, `{"paths":["a.go","b/c.go"],"truncated":false}`}, | |
| 181 | + | {"empty", nil, false, `{"paths":[],"truncated":false}`}, | |
| 182 | + | {"truncated", []string{"a.go"}, true, `{"paths":["a.go"],"truncated":true}`}, | |
| 183 | + | {"html characters", []string{"a&b/<c>.go"}, false, `{"paths":["a&b/<c>.go"],"truncated":false}`}, | |
| 184 | + | } | |
| 185 | + | for _, tt := range tests { | |
| 186 | + | t.Run(tt.name, func(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | browse := mocks.NewBrowseService(t) | |
| 189 | + | browse.EXPECT().ListPaths(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return( | |
| 190 | + | domain.PathList{CommitID: objectID(t, pathsSHA), Paths: tt.paths, Truncated: tt.truncated}, nil) | |
| 191 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |
| 192 | + | ctx := domain.ContextWithPrincipal(context.Background(), domain.Anonymous()) | |
| 193 | + | body, err := srv.buildPaths(ctx, repoName(t, "r"), pathsSHA) | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | assert.Equal(t, len(body), cap(body)) | |
| 196 | + | zr, err := gzip.NewReader(bytes.NewReader(body)) | |
| 197 | + | require.NoError(t, err) | |
| 198 | + | out, err := io.ReadAll(zr) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | assert.Equal(t, tt.want, strings.TrimSpace(string(out))) | |
| 201 | + | }) | |
| 158 | 202 | } | |
| 159 | 203 | } | |
| 160 | 204 | ||
| 161 | - | // A path is not HTML: "<", ">" and "&" are not escaped in the JSON. | |
| 162 | - | func TestBuildPathsDoesNotEscapeHTML(t *testing.T) { | |
| 163 | - | git := &pathsGit{paths: []string{"a&b/<c>.go"}} | |
| 164 | - | srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"}) | |
| 165 | - | require.NoError(t, err) | |
| 166 | - | body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40)) | |
| 167 | - | require.NoError(t, err) | |
| 168 | - | zr, err := gzip.NewReader(bytes.NewReader(body)) | |
| 169 | - | require.NoError(t, err) | |
| 170 | - | out, err := io.ReadAll(zr) | |
| 171 | - | require.NoError(t, err) | |
| 172 | - | assert.Contains(t, string(out), `"a&b/<c>.go"`) | |
| 205 | + | // A list that answers for another commit than the one asked is refused. | |
| 206 | + | func TestBuildPathsWrongCommit(t *testing.T) { | |
| 207 | + | t.Parallel() | |
| 208 | + | browse := mocks.NewBrowseService(t) | |
| 209 | + | browse.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( | |
| 210 | + | domain.PathList{CommitID: objectID(t, strings.Repeat("b", 40))}, nil) | |
| 211 | + | srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "r")), Browse: browse}) | |
| 212 | + | _, err := srv.buildPaths(context.Background(), repoName(t, "r"), pathsSHA) | |
| 213 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 173 | 214 | } | |
| 215 | + | ||
| 216 | + | var _ ports.BrowseService = (*mocks.BrowseService)(nil) | |
M internal/adapters/web/plaintext_test.go +6 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | // Plain text stays text; only http(s) addresses become links, without the | |
| 15 | 15 | // punctuation that ends a sentence; invisible characters are marked. | |
| 16 | 16 | func TestPlainParts(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 17 | 18 | tests := []struct { | |
| 18 | 19 | name, in string | |
| 19 | 20 | want []TextPart | |
| @@ -32,6 +33,7 @@ func TestPlainParts(t *testing.T) { | |||
| 32 | 33 | } | |
| 33 | 34 | for _, tt := range tests { | |
| 34 | 35 | t.Run(tt.name, func(t *testing.T) { | |
| 36 | + | t.Parallel() | |
| 35 | 37 | assert.Equal(t, tt.want, plainParts(tt.in)) | |
| 36 | 38 | }) | |
| 37 | 39 | } | |
| @@ -39,12 +41,14 @@ func TestPlainParts(t *testing.T) { | |||
| 39 | 41 | ||
| 40 | 42 | // userText escapes the text and the address, and marks the link as untrusted. | |
| 41 | 43 | func TestUserTextEscapes(t *testing.T) { | |
| 44 | + | t.Parallel() | |
| 42 | 45 | var b bytes.Buffer | |
| 43 | 46 | require.NoError(t, userText(plainParts(`<b>x</b> & http://x.test/?a=1&b=2`)).Render(context.Background(), &b)) | |
| 44 | 47 | assert.Equal(t, `<b>x</b> & <a href="http://x.test/?a=1&b=2" rel="noopener nofollow ugc">http://x.test/?a=1&b=2</a>`, b.String()) | |
| 45 | 48 | } | |
| 46 | 49 | ||
| 47 | 50 | func TestParseOn(t *testing.T) { | |
| 51 | + | t.Parallel() | |
| 48 | 52 | assert.Equal(t, &lineRef{side: "new", line: 12, path: "a/b:c.go"}, parseOn("new:12:a/b:c.go")) | |
| 49 | 53 | assert.Equal(t, &lineRef{side: "old", line: 1, path: "x"}, parseOn("old:1:x")) | |
| 50 | 54 | for _, s := range []string{"", "new", "new:12", "new:12:", "new:0:x", "new:-1:x", "new:x:y", "up:1:x", "new::x"} { | |
| @@ -53,6 +57,7 @@ func TestParseOn(t *testing.T) { | |||
| 53 | 57 | } | |
| 54 | 58 | ||
| 55 | 59 | func TestShellWord(t *testing.T) { | |
| 60 | + | t.Parallel() | |
| 56 | 61 | assert.Equal(t, "feat/search", shellWord("feat/search")) | |
| 57 | 62 | assert.Equal(t, `'a b'`, shellWord("a b")) | |
| 58 | 63 | assert.Equal(t, `'it'\''s'`, shellWord("it's")) | |
| @@ -61,6 +66,7 @@ func TestShellWord(t *testing.T) { | |||
| 61 | 66 | ||
| 62 | 67 | // The notes of a split row: a context line counts once. | |
| 63 | 68 | func TestSplitRowNotes(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 64 | 70 | a, b := CommentView{ID: "1"}, CommentView{ID: "2"} | |
| 65 | 71 | ctx := &DiffLine{Notes: []CommentView{a}} | |
| 66 | 72 | assert.Equal(t, []CommentView{a}, SplitRow{ctx, ctx}.Notes()) | |
M internal/adapters/web/raw.go +47 -24
| @@ -4,6 +4,8 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 7 | 9 | "mime" | |
| 8 | 10 | "net/http" | |
| 9 | 11 | "path" | |
| @@ -11,11 +13,16 @@ import ( | |||
| 11 | 13 | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/zerowrap" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | 18 | ) | |
| 15 | 19 | ||
| 16 | 20 | // rawTimeout bounds the stream of one file to the visitor. | |
| 17 | 21 | const rawTimeout = 5 * time.Minute | |
| 18 | 22 | ||
| 23 | + | // rawChunkSize is the size of the writes that stream a file to the visitor. | |
| 24 | + | const rawChunkSize = 64 << 10 | |
| 25 | + | ||
| 19 | 26 | // rawCSP makes a raw file inert if a browser opens it as a document: it can | |
| 20 | 27 | // load nothing and run nothing, whatever it contains. | |
| 21 | 28 | const rawCSP = "default-src 'none'; sandbox" | |
| @@ -24,10 +31,17 @@ const rawCSP = "default-src 'none'; sandbox" | |||
| 24 | 31 | // image types (by their bytes, never SVG) as images, and anything else as an | |
| 25 | 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | |
| 26 | 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 | |
| 27 | 36 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |
| 28 | 37 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | |
| 29 | 38 | defer cancel() | |
| 30 | - | b, t, repo, err := s.rawOpen(ctx, r) | |
| 39 | + | t, repo, err := s.rawTarget(ctx, r) | |
| 40 | + | if err != nil { | |
| 41 | + | s.fail(ctx, w, r, err) | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | b, err := s.openBlob(ctx, repo, t) | |
| 31 | 45 | if err != nil { | |
| 32 | 46 | s.fail(ctx, w, r, err) | |
| 33 | 47 | return | |
| @@ -44,7 +58,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||
| 44 | 58 | h.Set("Content-Type", typ) | |
| 45 | 59 | h.Set("X-Content-Type-Options", "nosniff") | |
| 46 | 60 | h.Set("Content-Security-Policy", rawCSP) | |
| 47 | - | h.Set("Content-Length", strconv.FormatInt(b.info.GetSize(), 10)) | |
| 61 | + | h.Set("Content-Length", strconv.FormatInt(b.info.Size, 10)) | |
| 48 | 62 | if attach { | |
| 49 | 63 | name := path.Base(t.path) | |
| 50 | 64 | cd := mime.FormatMediaType("attachment", map[string]string{"filename": name}) | |
| @@ -53,12 +67,15 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||
| 53 | 67 | } | |
| 54 | 68 | h.Set("Content-Disposition", cd) | |
| 55 | 69 | } | |
| 56 | - | if etag := b.info.GetId(); etag != "" { | |
| 70 | + | if etag := b.info.ID.String(); etag != "" { | |
| 57 | 71 | h.Set("ETag", `"`+etag+`"`) | |
| 58 | 72 | } | |
| 59 | 73 | // Revalidated every time, even at a full commit id: a repository can turn | |
| 60 | 74 | // private, and a shared cache must not keep serving its files (AC2). The | |
| 61 | - | // ETag keeps the revalidation cheap. | |
| 75 | + | // ETag spares the body: the blob id comes with the metadata, before the | |
| 76 | + | // content, and a match ends the answer there. No cheaper call gives the | |
| 77 | + | // id of the blob at a path: a tree listing of its directory costs more | |
| 78 | + | // than opening a small file. | |
| 62 | 79 | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 63 | 80 | if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) { | |
| 64 | 81 | w.WriteHeader(http.StatusNotModified) | |
| @@ -72,35 +89,41 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||
| 72 | 89 | return | |
| 73 | 90 | } | |
| 74 | 91 | b.buf = nil | |
| 75 | - | for b.stream.Receive() { | |
| 76 | - | if _, err := w.Write(b.stream.Msg().GetChunk()); err != nil { | |
| 92 | + | if b.eof { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | chunk := make([]byte, rawChunkSize) | |
| 96 | + | for { | |
| 97 | + | n, rerr := b.body.Read(chunk) | |
| 98 | + | if n > 0 { | |
| 99 | + | if _, err := w.Write(chunk[:n]); err != nil { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if errors.Is(rerr, io.EOF) { | |
| 77 | 104 | return | |
| 78 | 105 | } | |
| 79 | - | } | |
| 80 | - | if err := b.stream.Err(); err != nil { | |
| 81 | - | // Headers are gone: all that is left is to cut the answer short, so | |
| 82 | - | // that the client sees it is incomplete. | |
| 83 | - | log := zerowrap.FromCtx(ctx) | |
| 84 | - | log.Error().Err(err).Str("repo", repo).Str("path", r.URL.Path).Msg("raw stream failed") | |
| 85 | - | panic(http.ErrAbortHandler) | |
| 106 | + | if rerr != nil { | |
| 107 | + | // Headers are gone: all that is left is to cut the answer short, | |
| 108 | + | // so that the client sees it is incomplete. | |
| 109 | + | log := zerowrap.FromCtx(ctx) | |
| 110 | + | log.Error().Err(rerr).Str("repo", repo.String()).Str("path", logPath(r.URL.Path)).Msg("raw stream failed") | |
| 111 | + | panic(http.ErrAbortHandler) | |
| 112 | + | } | |
| 86 | 113 | } | |
| 87 | 114 | } | |
| 88 | 115 | ||
| 89 | - | // rawOpen finds the file a raw URL designates and opens its stream. | |
| 90 | - | func (s *Server) rawOpen(ctx context.Context, r *http.Request) (*blobStream, target, string, error) { | |
| 116 | + | // rawTarget finds the file a raw URL designates and the name of its repository. | |
| 117 | + | func (s *Server) rawTarget(ctx context.Context, r *http.Request) (target, domain.RepoName, error) { | |
| 91 | 118 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 92 | 119 | if err != nil { | |
| 93 | - | return nil, target{}, "", err | |
| 120 | + | return target{}, domain.RepoName{}, err | |
| 94 | 121 | } | |
| 95 | 122 | t, ok := d.locate(r.PathValue("rest")) | |
| 96 | - | if !ok { | |
| 97 | - | return nil, target{}, "", errNotFound | |
| 98 | - | } | |
| 99 | - | b, err := s.openBlob(ctx, d.name(), t) | |
| 100 | - | if err != nil { | |
| 101 | - | return nil, target{}, "", err | |
| 123 | + | if !ok || t.path == "" { | |
| 124 | + | return target{}, domain.RepoName{}, errNotFound | |
| 102 | 125 | } | |
| 103 | - | return b, t, d.name(), nil | |
| 126 | + | return t, d.repo.Name, nil | |
| 104 | 127 | } | |
| 105 | 128 | ||
| 106 | 129 | // rawType is the Content-Type of a file and whether it is an attachment. | |
| @@ -109,7 +132,7 @@ func rawType(b *blobStream) (typ string, attachment bool) { | |||
| 109 | 132 | if img := sniffImage(b.buf); img != "" { | |
| 110 | 133 | return img, false | |
| 111 | 134 | } | |
| 112 | - | if !b.info.GetBinary() { | |
| 135 | + | if !b.info.Binary { | |
| 113 | 136 | return "text/plain; charset=utf-8", false | |
| 114 | 137 | } | |
| 115 | 138 | return "application/octet-stream", true | |
A internal/adapters/web/raw_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | blobID = "0123456789abcdef0123456789abcdef01234567" | |
| 23 | + | mainTip = "c0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe" | |
| 24 | + | blobBody = "hello" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // rawBrowse serves one text file, the only call raw makes besides the refs. | |
| 28 | + | func rawBrowse(t *testing.T) *mocks.BrowseService { | |
| 29 | + | t.Helper() | |
| 30 | + | browse := mocks.NewBrowseService(t) | |
| 31 | + | browse.EXPECT().ListRefs(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything).Return(ports.RefPage{ | |
| 32 | + | Refs: []domain.Ref{{ | |
| 33 | + | Name: "refs/heads/main", ShortName: "main", Kind: domain.RefKindBranch, | |
| 34 | + | ID: objectID(t, mainTip), CommitID: objectID(t, mainTip), | |
| 35 | + | }}, | |
| 36 | + | }, nil).Maybe() | |
| 37 | + | browse.EXPECT().OpenBlob(mock.Anything, domain.Anonymous(), repoName(t, "r"), mock.Anything, mock.Anything).RunAndReturn( | |
| 38 | + | func(_ context.Context, _ domain.Principal, _ domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |
| 39 | + | if path.String() != "docs/a.txt" || rev.String() != mainTip { | |
| 40 | + | return domain.BlobInfo{}, nil, domain.ErrNotFound | |
| 41 | + | } | |
| 42 | + | return domain.BlobInfo{ID: objectID(t, blobID), Path: "docs/a.txt", Size: int64(len(blobBody)), MediaType: "text/plain"}, | |
| 43 | + | io.NopCloser(strings.NewReader(blobBody)), nil | |
| 44 | + | }).Maybe() | |
| 45 | + | return browse | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // A revalidation of an unchanged file ends with a 304 and no body. The blob is | |
| 49 | + | // opened in every case: its id is only known from the metadata the use case | |
| 50 | + | // returns with the content. | |
| 51 | + | func TestRawRevalidation(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 53 | + | tests := []struct { | |
| 54 | + | name string | |
| 55 | + | url string | |
| 56 | + | method string | |
| 57 | + | ifNone string | |
| 58 | + | want int | |
| 59 | + | body string | |
| 60 | + | }{ | |
| 61 | + | {"no validator", "/r/raw/main/docs/a.txt", http.MethodGet, "", http.StatusOK, "hello"}, | |
| 62 | + | {"matching validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotModified, ""}, | |
| 63 | + | {"other validator", "/r/raw/main/docs/a.txt", http.MethodGet, `"ffffffffffffffffffffffffffffffffffffffff"`, http.StatusOK, "hello"}, | |
| 64 | + | {"weak validator matches (weak comparison)", "/r/raw/main/docs/a.txt", http.MethodGet, `W/"` + blobID + `"`, http.StatusNotModified, ""}, | |
| 65 | + | {"list of validators", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "` + blobID + `"`, http.StatusNotModified, ""}, | |
| 66 | + | {"any validator", "/r/raw/main/docs/a.txt", http.MethodGet, "*", http.StatusNotModified, ""}, | |
| 67 | + | {"list without a match", "/r/raw/main/docs/a.txt", http.MethodGet, `"other", "another"`, http.StatusOK, "hello"}, | |
| 68 | + | {"head", "/r/raw/main/docs/a.txt", http.MethodHead, "", http.StatusOK, ""}, | |
| 69 | + | {"missing file", "/r/raw/main/docs/none.txt", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |
| 70 | + | {"directory", "/r/raw/main", http.MethodGet, `"` + blobID + `"`, http.StatusNotFound, ""}, | |
| 71 | + | } | |
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | repos := reposServing(t, publicRepo(t, "r")) | |
| 76 | + | srv := newTestServer(t, Config{Repos: repos, Browse: rawBrowse(t)}) | |
| 77 | + | req := httptest.NewRequest(tt.method, tt.url, nil) | |
| 78 | + | if tt.ifNone != "" { | |
| 79 | + | req.Header.Set("If-None-Match", tt.ifNone) | |
| 80 | + | } | |
| 81 | + | rec := httptest.NewRecorder() | |
| 82 | + | srv.ServeHTTP(rec, req) | |
| 83 | + | ||
| 84 | + | assert.Equal(t, tt.want, rec.Code) | |
| 85 | + | if tt.want != http.StatusNotFound { | |
| 86 | + | assert.Equal(t, tt.body, rec.Body.String()) | |
| 87 | + | } | |
| 88 | + | if tt.want == http.StatusNotModified { | |
| 89 | + | assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag")) | |
| 90 | + | assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) | |
| 91 | + | } | |
| 92 | + | if tt.want == http.StatusOK { | |
| 93 | + | assert.Equal(t, `"`+blobID+`"`, rec.Header().Get("ETag")) | |
| 94 | + | assert.Equal(t, rawCSP, rec.Header().Get("Content-Security-Policy")) | |
| 95 | + | } | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
M internal/adapters/web/refs.go +24 -22
| @@ -7,9 +7,7 @@ import ( | |||
| 7 | 7 | "net/url" | |
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | - | "connectrpc.com/connect" | |
| 11 | - | ||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | 11 | ) | |
| 14 | 12 | ||
| 15 | 13 | // resolveRef finds the ref a URL path starts with and the path that follows. | |
| @@ -17,7 +15,9 @@ import ( | |||
| 17 | 15 | // that is the short name of a branch or tag wins. A branch wins over a tag of | |
| 18 | 16 | // the same name. rest has no leading slash and is "" when the path is the ref | |
| 19 | 17 | // alone. ok is false when no ref matches; the caller may then try a commit id. | |
| 20 | - | func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |
| 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) { | |
| 21 | 21 | names := refsByName(refs) | |
| 22 | 22 | p = strings.Trim(p, "/") | |
| 23 | 23 | for end := len(p); end > 0; end = strings.LastIndex(p[:end], "/") { | |
| @@ -25,21 +25,21 @@ func resolveRef(refs []*pb.Ref, p string) (ref *pb.Ref, rest string, ok bool) { | |||
| 25 | 25 | return r, strings.TrimPrefix(p[end:], "/"), true | |
| 26 | 26 | } | |
| 27 | 27 | } | |
| 28 | - | return nil, "", false | |
| 28 | + | return domain.Ref{}, "", false | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | // refsByName indexes the branches and tags by short name. A branch wins over | |
| 32 | 32 | // a tag of the same name. | |
| 33 | - | func refsByName(refs []*pb.Ref) map[string]*pb.Ref { | |
| 34 | - | names := make(map[string]*pb.Ref, len(refs)) | |
| 33 | + | func refsByName(refs []domain.Ref) map[string]domain.Ref { | |
| 34 | + | names := make(map[string]domain.Ref, len(refs)) | |
| 35 | 35 | for _, r := range refs { | |
| 36 | - | k := r.GetKind() | |
| 37 | - | if k != pb.RefKind_REF_KIND_BRANCH && k != pb.RefKind_REF_KIND_TAG { | |
| 36 | + | k := r.Kind | |
| 37 | + | if k != domain.RefKindBranch && k != domain.RefKindTag { | |
| 38 | 38 | continue | |
| 39 | 39 | } | |
| 40 | - | cur, dup := names[r.GetShortName()] | |
| 41 | - | if !dup || cur.GetKind() != pb.RefKind_REF_KIND_BRANCH { | |
| 42 | - | names[r.GetShortName()] = r | |
| 40 | + | cur, dup := names[r.ShortName] | |
| 41 | + | if !dup || cur.Kind != domain.RefKindBranch { | |
| 42 | + | names[r.ShortName] = r | |
| 43 | 43 | } | |
| 44 | 44 | } | |
| 45 | 45 | return names | |
| @@ -71,8 +71,8 @@ func repoHref(repo string, parts ...string) string { | |||
| 71 | 71 | // target is what the URL of a tree, blob, raw or commits page designates: a | |
| 72 | 72 | // commit, the name it was reached by, and a path in its tree. | |
| 73 | 73 | type target struct { | |
| 74 | - | // rev is what the API is asked for: the id of the commit of a ref, or | |
| 75 | - | // the commit id of the URL itself. | |
| 74 | + | // rev is what the use cases are asked for: the id of the commit of a ref, | |
| 75 | + | // or the commit id of the URL itself. | |
| 76 | 76 | rev string | |
| 77 | 77 | // name is how the URL spells the ref: a branch or a tag, or the commit id | |
| 78 | 78 | // when detached. | |
| @@ -97,32 +97,34 @@ func (t target) commitID() string { | |||
| 97 | 97 | // resolveCommit sets the full id of a target named by an abbreviated commit id, | |
| 98 | 98 | // with one ResolveRevision. The page renders without it (it only lacks the file | |
| 99 | 99 | // finder), so a failure is not one. | |
| 100 | - | func (s *Server) resolveCommit(ctx context.Context, repo string, t *target) { | |
| 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) { | |
| 101 | 103 | if !t.detached || isFullID(t.rev) { | |
| 102 | 104 | return | |
| 103 | 105 | } | |
| 104 | - | res, err := s.cfg.Git.ResolveRevision(ctx, connect.NewRequest(&pb.ResolveRevisionRequest{Repo: repo, Revision: t.rev})) | |
| 106 | + | res, err := s.resolveRevision(ctx, repo, t.rev) | |
| 105 | 107 | if err != nil { | |
| 106 | 108 | return | |
| 107 | 109 | } | |
| 108 | - | t.commit = res.Msg.GetCommitId() | |
| 110 | + | t.commit = res.CommitID.String() | |
| 109 | 111 | } | |
| 110 | 112 | ||
| 111 | 113 | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | |
| 112 | 114 | // longest prefix of the path that is the short name of a branch or a tag | |
| 113 | 115 | // (names may contain "/"); a branch wins over a tag of the same name. An | |
| 114 | 116 | // empty rest designates the head of the repository. Whether the commit or | |
| 115 | - | // the path exists is for the API to say. | |
| 117 | + | // the path exists is for the use cases to say. | |
| 116 | 118 | func (d *repoData) locate(rest string) (target, bool) { | |
| 117 | 119 | rest = strings.Trim(rest, "/") | |
| 118 | 120 | if rest == "" { | |
| 119 | 121 | if d.head == nil { | |
| 120 | 122 | return target{}, false | |
| 121 | 123 | } | |
| 122 | - | return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | |
| 124 | + | return target{rev: d.head.CommitID.String(), name: d.head.ShortName}, true | |
| 123 | 125 | } | |
| 124 | - | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && ref.GetCommitId() != "" { | |
| 125 | - | return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | |
| 126 | + | if ref, p, ok := resolveRef(d.allRefs(), rest); ok && !ref.CommitID.IsZero() { | |
| 127 | + | return target{rev: ref.CommitID.String(), name: ref.ShortName, path: p}, true | |
| 126 | 128 | } | |
| 127 | 129 | first, p, _ := strings.Cut(rest, "/") | |
| 128 | 130 | if isCommitID(first) { | |
| @@ -152,7 +154,7 @@ func (d *repoData) isBranchHead(t target) bool { | |||
| 152 | 154 | return false | |
| 153 | 155 | } | |
| 154 | 156 | for _, b := range d.branches { | |
| 155 | - | if b.GetShortName() == t.name && b.GetCommitId() == t.rev { | |
| 157 | + | if b.ShortName == t.name && b.CommitID.String() == t.rev { | |
| 156 | 158 | return true | |
| 157 | 159 | } | |
| 158 | 160 | } | |
M internal/adapters/web/refs_test.go +29 -12
| @@ -3,27 +3,42 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "crypto/sha1" // #nosec G505 -- ids for tests | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/assert" | |
| 9 | 11 | ||
| 10 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | - | func ref(kind pb.RefKind, short string) *pb.Ref { | |
| 14 | - | return &pb.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, CommitId: "c-" + short} | |
| 15 | + | // commitOf is a commit id derived from a name, so that two refs of one name | |
| 16 | + | // share their commit. | |
| 17 | + | func commitOf(name string) domain.ObjectID { | |
| 18 | + | sum := sha1.Sum([]byte(name)) // #nosec G401 -- an id for a test, not a hash for security | |
| 19 | + | id, err := domain.ParseObjectID(hex.EncodeToString(sum[:])) | |
| 20 | + | if err != nil { | |
| 21 | + | panic(err) | |
| 22 | + | } | |
| 23 | + | return id | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func ref(kind domain.RefKind, short string) domain.Ref { | |
| 27 | + | id := commitOf(short) | |
| 28 | + | return domain.Ref{Name: "refs/x/" + short, ShortName: short, Kind: kind, ID: id, CommitID: id} | |
| 15 | 29 | } | |
| 16 | 30 | ||
| 17 | 31 | // AC3: a ref with slashes is found by the longest matching prefix. | |
| 18 | 32 | func TestResolveRef(t *testing.T) { | |
| 19 | - | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 20 | - | refs := []*pb.Ref{ | |
| 33 | + | t.Parallel() | |
| 34 | + | b, tag := domain.RefKindBranch, domain.RefKindTag | |
| 35 | + | refs := []domain.Ref{ | |
| 21 | 36 | ref(b, "main"), ref(b, "feature"), ref(b, "feature/x"), ref(b, "feature/x/y"), ref(tag, "v1/rc"), | |
| 22 | - | ref(pb.RefKind_REF_KIND_HEAD, "main"), ref(tag, "main"), | |
| 37 | + | ref(domain.RefKindHead, "main"), ref(tag, "main"), | |
| 23 | 38 | } | |
| 24 | 39 | tests := []struct { | |
| 25 | 40 | path, ref, rest string | |
| 26 | - | kind pb.RefKind | |
| 41 | + | kind domain.RefKind | |
| 27 | 42 | ok bool | |
| 28 | 43 | }{ | |
| 29 | 44 | {"main", "main", "", b, true}, | |
| @@ -33,25 +48,27 @@ func TestResolveRef(t *testing.T) { | |||
| 33 | 48 | {"feature/other", "feature", "other", b, true}, | |
| 34 | 49 | {"/feature/x/", "feature/x", "", b, true}, | |
| 35 | 50 | {"v1/rc/README", "v1/rc", "README", tag, true}, | |
| 36 | - | {"v1/README", "", "", 0, false}, | |
| 37 | - | {"nope/main", "", "", 0, false}, | |
| 38 | - | {"", "", "", 0, false}, | |
| 51 | + | {"v1/README", "", "", "", false}, | |
| 52 | + | {"nope/main", "", "", "", false}, | |
| 53 | + | {"", "", "", "", false}, | |
| 39 | 54 | } | |
| 40 | 55 | for _, tt := range tests { | |
| 41 | 56 | t.Run(tt.path, func(t *testing.T) { | |
| 57 | + | t.Parallel() | |
| 42 | 58 | r, rest, ok := resolveRef(refs, tt.path) | |
| 43 | 59 | assert.Equal(t, tt.ok, ok) | |
| 44 | 60 | if !ok { | |
| 45 | 61 | return | |
| 46 | 62 | } | |
| 47 | - | assert.Equal(t, tt.ref, r.GetShortName()) | |
| 48 | - | assert.Equal(t, tt.kind, r.GetKind(), "a branch wins over a tag of the same name") | |
| 63 | + | assert.Equal(t, tt.ref, r.ShortName) | |
| 64 | + | assert.Equal(t, tt.kind, r.Kind, "a branch wins over a tag of the same name") | |
| 49 | 65 | assert.Equal(t, tt.rest, rest) | |
| 50 | 66 | }) | |
| 51 | 67 | } | |
| 52 | 68 | } | |
| 53 | 69 | ||
| 54 | 70 | func TestRepoHref(t *testing.T) { | |
| 71 | + | t.Parallel() | |
| 55 | 72 | assert.Equal(t, "/demo", repoHref("demo")) | |
| 56 | 73 | assert.Equal(t, "/demo/tree/feature/x/a%20b/c%23.go", repoHref("demo", "tree", "feature/x", "a b/c#.go")) | |
| 57 | 74 | assert.Equal(t, "/demo/blob/main/%3Fq", repoHref("demo", "blob", "main", "", "?q")) | |
M internal/adapters/web/refspage.go +16 -15
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/a-h/templ" | |
| 11 | 11 | ||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | // refs lists the branches and the tags of a repository with the commit each | |
| @@ -33,27 +33,27 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 33 | 33 | // refsPage is the content of the refs page, which the live updates of the | |
| 34 | 34 | // page render again. | |
| 35 | 35 | func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |
| 36 | - | commits := s.commitsOf(ctx, d.name(), d.allRefs()) | |
| 36 | + | commits := s.commitsOf(ctx, d.repo.Name, d.allRefs()) | |
| 37 | 37 | ||
| 38 | - | def := d.repo.GetDefaultBranch() | |
| 38 | + | def := d.repo.DefaultBranch | |
| 39 | 39 | p := RefsPage{Default: def} | |
| 40 | - | branches := append([]*pb.Ref(nil), d.branches...) | |
| 40 | + | branches := append([]domain.Ref(nil), d.branches...) | |
| 41 | 41 | sort.SliceStable(branches, func(i, j int) bool { | |
| 42 | - | a, b := branches[i].GetShortName() == def, branches[j].GetShortName() == def | |
| 42 | + | a, b := branches[i].ShortName == def, branches[j].ShortName == def | |
| 43 | 43 | return a && !b | |
| 44 | 44 | }) | |
| 45 | 45 | for i, b := range branches { | |
| 46 | - | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.GetShortName() == def, i == 0)) | |
| 46 | + | p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0)) | |
| 47 | 47 | } | |
| 48 | - | tags := append([]*pb.Ref(nil), d.tags...) | |
| 48 | + | tags := append([]domain.Ref(nil), d.tags...) | |
| 49 | 49 | sort.SliceStable(tags, func(i, j int) bool { | |
| 50 | - | return commits[tags[i].GetCommitId()].at > commits[tags[j].GetCommitId()].at | |
| 50 | + | return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at | |
| 51 | 51 | }) | |
| 52 | 52 | for i, t := range tags { | |
| 53 | 53 | p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0)) | |
| 54 | 54 | } | |
| 55 | 55 | if len(tags) > 0 { | |
| 56 | - | p.Latest = tags[0].GetShortName() | |
| 56 | + | p.Latest = tags[0].ShortName | |
| 57 | 57 | } | |
| 58 | 58 | if d.cut { | |
| 59 | 59 | p.Note = "this repository has more refs than are listed here." | |
| @@ -61,15 +61,16 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage { | |||
| 61 | 61 | return p | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 65 | - | c := commits[r.GetCommitId()] | |
| 64 | + | func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow { | |
| 65 | + | id := r.CommitID.String() | |
| 66 | + | c := commits[id] | |
| 66 | 67 | row := RefRow{ | |
| 67 | - | Name: r.GetShortName(), Href: repoHref(d.name(), "tree", r.GetShortName()), | |
| 68 | - | Short: short(r.GetCommitId()), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | |
| 68 | + | Name: r.ShortName, Href: repoHref(d.name(), "tree", r.ShortName), | |
| 69 | + | Short: short(id), Subject: c.subject, When: c.when, WhenAt: c.whenAt, | |
| 69 | 70 | Default: isDefault, Cursor: cursor, | |
| 70 | 71 | } | |
| 71 | - | if r.GetCommitId() != "" { | |
| 72 | - | row.CHref = repoHref(d.name(), "commit", r.GetCommitId()) | |
| 72 | + | if id != "" { | |
| 73 | + | row.CHref = repoHref(d.name(), "commit", id) | |
| 73 | 74 | } | |
| 74 | 75 | return row | |
| 75 | 76 | } | |
M internal/adapters/web/repo.go +84 -53
| @@ -10,10 +10,8 @@ import ( | |||
| 10 | 10 | "sync" | |
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | - | "connectrpc.com/connect" | |
| 14 | - | "google.golang.org/protobuf/types/known/timestamppb" | |
| 15 | - | ||
| 16 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | 15 | ) | |
| 18 | 16 | ||
| 19 | 17 | // maxRefs bounds the refs one page reads. A repository with more has its | |
| @@ -23,44 +21,69 @@ const maxRefs = 1000 | |||
| 23 | 21 | // repoData is what the pages of one repository start from: its metadata and | |
| 24 | 22 | // its refs, read once per request. | |
| 25 | 23 | type repoData struct { | |
| 26 | - | repo *pb.Repo | |
| 27 | - | branches []*pb.Ref | |
| 28 | - | tags []*pb.Ref | |
| 24 | + | repo ports.RepoView | |
| 25 | + | branches []domain.Ref | |
| 26 | + | tags []domain.Ref | |
| 29 | 27 | // cut is true when the repository has more refs than maxRefs. | |
| 30 | 28 | cut bool | |
| 31 | 29 | // head is the branch the repository opens on: its default branch, or the | |
| 32 | 30 | // first one when the default does not exist. nil for an empty repository. | |
| 33 | - | head *pb.Ref | |
| 31 | + | head *domain.Ref | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // repoView reads a repository as the caller of the request may see it, with | |
| 35 | + | // the Git state of its summary. A missing or private repository is | |
| 36 | + | // domain.ErrNotFound. | |
| 37 | + | func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) { | |
| 38 | + | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | |
| 39 | + | if err != nil { | |
| 40 | + | return ports.RepoView{}, err | |
| 41 | + | } | |
| 42 | + | view, err := s.cfg.Repos.View(ctx, repo) | |
| 43 | + | if err != nil { | |
| 44 | + | return ports.RepoView{}, err | |
| 45 | + | } | |
| 46 | + | if view.Settings.IsZero() { | |
| 47 | + | view.Settings = domain.DefaultRepoSettings() | |
| 48 | + | } | |
| 49 | + | return view, nil | |
| 34 | 50 | } | |
| 35 | 51 | ||
| 36 | 52 | // loadRepo reads a repository and its refs. A missing, private or invalidly | |
| 37 | - | // named repository is a not_found / invalid_argument error of the API, which | |
| 38 | - | // the caller turns into the 404 page. | |
| 39 | - | func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |
| 40 | - | res, err := s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name})) | |
| 53 | + | // named repository is a domain.ErrNotFound / invalid name error, which the | |
| 54 | + | // caller turns into the 404 page. | |
| 55 | + | func (s *Server) loadRepo(ctx context.Context, rawName string) (*repoData, error) { | |
| 56 | + | name, err := domain.ParseRepoName(rawName) | |
| 57 | + | if err != nil { | |
| 58 | + | return nil, err | |
| 59 | + | } | |
| 60 | + | view, err := s.repoView(ctx, name) | |
| 41 | 61 | if err != nil { | |
| 42 | 62 | return nil, err | |
| 43 | 63 | } | |
| 44 | - | d := &repoData{repo: res.Msg.GetRepo()} | |
| 64 | + | d := &repoData{repo: view} | |
| 65 | + | // Each page of ListRefs runs the whole for-each-ref again: the refs are | |
| 66 | + | // asked in one page of up to maxRefs (the use case lowers it to its cap, | |
| 67 | + | // and the token then leads to the rest). | |
| 45 | 68 | token, n := "", 0 | |
| 46 | 69 | for { | |
| 47 | - | page, err := s.cfg.Git.ListRefs(ctx, connect.NewRequest(&pb.ListRefsRequest{ | |
| 48 | - | Repo: name, PageSize: pageSize, PageToken: token, | |
| 49 | - | Kinds: []pb.RefKind{pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG}, | |
| 50 | - | })) | |
| 70 | + | page, err := s.cfg.Browse.ListRefs(ctx, principal(ctx), name, ports.ListRefsParams{ | |
| 71 | + | Kinds: []domain.RefKind{domain.RefKindBranch, domain.RefKindTag}, | |
| 72 | + | PageSize: maxRefs, PageToken: token, | |
| 73 | + | }) | |
| 51 | 74 | if err != nil { | |
| 52 | 75 | return nil, err | |
| 53 | 76 | } | |
| 54 | - | for _, r := range page.Msg.GetRefs() { | |
| 55 | - | switch r.GetKind() { | |
| 56 | - | case pb.RefKind_REF_KIND_BRANCH: | |
| 77 | + | for _, r := range page.Refs { | |
| 78 | + | switch r.Kind { | |
| 79 | + | case domain.RefKindBranch: | |
| 57 | 80 | d.branches = append(d.branches, r) | |
| 58 | - | case pb.RefKind_REF_KIND_TAG: | |
| 81 | + | case domain.RefKindTag: | |
| 59 | 82 | d.tags = append(d.tags, r) | |
| 60 | 83 | } | |
| 61 | 84 | n++ | |
| 62 | 85 | } | |
| 63 | - | if token = page.Msg.GetNextPageToken(); token == "" { | |
| 86 | + | if token = page.NextPageToken; token == "" { | |
| 64 | 87 | break | |
| 65 | 88 | } | |
| 66 | 89 | if n >= maxRefs { | |
| @@ -68,24 +91,24 @@ func (s *Server) loadRepo(ctx context.Context, name string) (*repoData, error) { | |||
| 68 | 91 | break | |
| 69 | 92 | } | |
| 70 | 93 | } | |
| 71 | - | for _, b := range d.branches { | |
| 72 | - | if b.GetShortName() == d.repo.GetDefaultBranch() { | |
| 73 | - | d.head = b | |
| 94 | + | for i := range d.branches { | |
| 95 | + | if d.branches[i].ShortName == d.repo.DefaultBranch { | |
| 96 | + | d.head = &d.branches[i] | |
| 74 | 97 | } | |
| 75 | 98 | } | |
| 76 | 99 | if d.head == nil && len(d.branches) > 0 { | |
| 77 | - | d.head = d.branches[0] | |
| 100 | + | d.head = &d.branches[0] | |
| 78 | 101 | } | |
| 79 | 102 | return d, nil | |
| 80 | 103 | } | |
| 81 | 104 | ||
| 82 | 105 | // allRefs is the branches then the tags. | |
| 83 | - | func (d *repoData) allRefs() []*pb.Ref { | |
| 84 | - | return append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 106 | + | func (d *repoData) allRefs() []domain.Ref { | |
| 107 | + | return append(append([]domain.Ref(nil), d.branches...), d.tags...) | |
| 85 | 108 | } | |
| 86 | 109 | ||
| 87 | 110 | // name is the repository name. | |
| 88 | - | func (d *repoData) name() string { return d.repo.GetName() } | |
| 111 | + | func (d *repoData) name() string { return d.repo.Name.String() } | |
| 89 | 112 | ||
| 90 | 113 | // layout is the Layout of the pages of a repository: the sections, the ref | |
| 91 | 114 | // switcher on the head ref and the ":" targets. The caller sets the title, | |
| @@ -93,11 +116,11 @@ func (d *repoData) name() string { return d.repo.GetName() } | |||
| 93 | 116 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |
| 94 | 117 | l := s.base() | |
| 95 | 118 | l.Repo = d.name() | |
| 96 | - | def := d.repo.GetDefaultBranch() | |
| 119 | + | def := d.repo.DefaultBranch | |
| 97 | 120 | if d.head != nil { | |
| 98 | - | def = d.head.GetShortName() | |
| 121 | + | def = d.head.ShortName | |
| 99 | 122 | l.Ref, l.RefPath = def, def | |
| 100 | - | l.SHA, l.Short = d.head.GetCommitId(), short(d.head.GetCommitId()) | |
| 123 | + | l.SHA, l.Short = d.head.CommitID.String(), short(d.head.CommitID.String()) | |
| 101 | 124 | l.setFinder(l.SHA, def) | |
| 102 | 125 | } | |
| 103 | 126 | mk := func(key, label, href, id string) Section { | |
| @@ -120,12 +143,12 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 120 | 143 | } | |
| 121 | 144 | if l.Ref != "" { | |
| 122 | 145 | for _, b := range d.branches { | |
| 123 | - | it := RefItem{Name: b.GetShortName(), Href: repoHref(d.name(), "tree", b.GetShortName()), Current: b == d.head} | |
| 146 | + | it := RefItem{Name: b.ShortName, Href: repoHref(d.name(), "tree", b.ShortName), Current: b.ShortName == d.head.ShortName} | |
| 124 | 147 | l.Heads = append(l.Heads, it) | |
| 125 | 148 | l.Cmd = append(l.Cmd, CmdItem{Kind: "branch", Label: it.Name, Href: it.Href}) | |
| 126 | 149 | } | |
| 127 | 150 | for _, t := range d.tags { | |
| 128 | - | it := RefItem{Name: t.GetShortName(), Href: repoHref(d.name(), "tree", t.GetShortName())} | |
| 151 | + | it := RefItem{Name: t.ShortName, Href: repoHref(d.name(), "tree", t.ShortName)} | |
| 129 | 152 | l.Tags = append(l.Tags, it) | |
| 130 | 153 | l.Cmd = append(l.Cmd, CmdItem{Kind: "tag", Label: it.Name, Href: it.Href}) | |
| 131 | 154 | } | |
| @@ -151,7 +174,7 @@ type refCommit struct { | |||
| 151 | 174 | ||
| 152 | 175 | // commitsOf reads the head commit of each ref, a few at a time. A ref whose | |
| 153 | 176 | // commit cannot be read (a tag on a tree, say) has no entry. | |
| 154 | - | func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map[string]refCommit { | |
| 177 | + | func (s *Server) commitsOf(ctx context.Context, repo domain.RepoName, refs []domain.Ref) map[string]refCommit { | |
| 155 | 178 | const workers = 8 | |
| 156 | 179 | var ( | |
| 157 | 180 | mu sync.Mutex | |
| @@ -161,10 +184,10 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||
| 161 | 184 | ) | |
| 162 | 185 | now := s.cfg.Now() | |
| 163 | 186 | for _, r := range refs { | |
| 164 | - | id := r.GetCommitId() | |
| 165 | - | if id == "" { | |
| 187 | + | if r.CommitID.IsZero() { | |
| 166 | 188 | continue | |
| 167 | 189 | } | |
| 190 | + | id := r.CommitID.String() | |
| 168 | 191 | mu.Lock() | |
| 169 | 192 | _, done := out[id] | |
| 170 | 193 | if !done { | |
| @@ -179,12 +202,12 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||
| 179 | 202 | go func() { | |
| 180 | 203 | defer func() { <-sem; wg.Done() }() | |
| 181 | 204 | // By id, not by name: a branch and a tag may share a name. | |
| 182 | - | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: repo, Revision: id, PageSize: 1})) | |
| 205 | + | res, err := s.commitList(ctx, repo, id, "", 1, "") | |
| 183 | 206 | var rc refCommit | |
| 184 | - | if err == nil && len(res.Msg.GetCommits()) == 1 { | |
| 185 | - | row := commitRow(repo, res.Msg.GetCommits()[0], now) | |
| 207 | + | if err == nil && len(res.Commits) == 1 { | |
| 208 | + | row := commitRow(repo.String(), res.Commits[0], now) | |
| 186 | 209 | rc.short, rc.subject, rc.when, rc.whenAt = row.Short, row.Subject, row.When, row.WhenAt | |
| 187 | - | rc.at = res.Msg.GetCommits()[0].GetCommitter().GetTime().GetSeconds() | |
| 210 | + | rc.at = unixSeconds(res.Commits[0].Committer.Time) | |
| 188 | 211 | } | |
| 189 | 212 | mu.Lock() | |
| 190 | 213 | out[id] = rc | |
| @@ -196,32 +219,40 @@ func (s *Server) commitsOf(ctx context.Context, repo string, refs []*pb.Ref) map | |||
| 196 | 219 | } | |
| 197 | 220 | ||
| 198 | 221 | // whenOf is the relative and the machine form of a time; both are "" when | |
| 199 | - | // the time is unknown. | |
| 200 | - | func whenOf(now time.Time, ts *timestamppb.Timestamp) (when, at string) { | |
| 201 | - | if !ts.IsValid() { | |
| 222 | + | // the time is unknown (the zero time). | |
| 223 | + | func whenOf(now time.Time, t time.Time) (when, at string) { | |
| 224 | + | if t.IsZero() { | |
| 202 | 225 | return "", "" | |
| 203 | 226 | } | |
| 204 | - | return ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | |
| 227 | + | return ago(now, t), rfc3339(t) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // unixSeconds is the Unix time of t in seconds, 0 for the zero time. | |
| 231 | + | func unixSeconds(t time.Time) int64 { | |
| 232 | + | if t.IsZero() { | |
| 233 | + | return 0 | |
| 234 | + | } | |
| 235 | + | return t.Unix() | |
| 205 | 236 | } | |
| 206 | 237 | ||
| 207 | 238 | // commitRow is the row every list of commits starts from: the short id, the | |
| 208 | 239 | // subject, the author and the date of the committer, all fit to display. The | |
| 209 | 240 | // caller adds what is specific to its list. | |
| 210 | - | func commitRow(repo string, c *pb.Commit, now time.Time) CommitRow { | |
| 241 | + | func commitRow(repo string, c domain.Commit, now time.Time) CommitRow { | |
| 211 | 242 | row := CommitRow{ | |
| 212 | - | Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), | |
| 213 | - | CHref: repoHref(repo, "commit", c.GetId()), | |
| 214 | - | Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1, | |
| 243 | + | Short: short(c.ID.String()), Subject: visibleText(c.Summary), | |
| 244 | + | CHref: repoHref(repo, "commit", c.ID.String()), | |
| 245 | + | Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1, | |
| 215 | 246 | } | |
| 216 | - | row.When, row.WhenAt = whenOf(now, c.GetCommitter().GetTime()) | |
| 247 | + | row.When, row.WhenAt = whenOf(now, c.Committer.Time) | |
| 217 | 248 | return row | |
| 218 | 249 | } | |
| 219 | 250 | ||
| 220 | 251 | // namesAt maps commit ids to the names of the refs that designate them. | |
| 221 | - | func namesAt(refs []*pb.Ref) map[string][]string { | |
| 252 | + | func namesAt(refs []domain.Ref) map[string][]string { | |
| 222 | 253 | m := map[string][]string{} | |
| 223 | 254 | for _, r := range refs { | |
| 224 | - | m[r.GetCommitId()] = append(m[r.GetCommitId()], r.GetShortName()) | |
| 255 | + | m[r.CommitID.String()] = append(m[r.CommitID.String()], r.ShortName) | |
| 225 | 256 | } | |
| 226 | 257 | for _, v := range m { | |
| 227 | 258 | sort.Strings(v) | |
| @@ -235,7 +266,7 @@ func (s *Server) emptyState(ctx context.Context, d *repoData) (Layout, StatePage | |||
| 235 | 266 | l.Title = d.name() + " · " + s.instance | |
| 236 | 267 | l.Sections = nil | |
| 237 | 268 | l.Cmd = slices.DeleteFunc(l.Cmd, func(c CmdItem) bool { return c.Kind == "section" }) | |
| 238 | - | branch := d.repo.GetDefaultBranch() | |
| 269 | + | branch := d.repo.DefaultBranch | |
| 239 | 270 | return l, StatePage{ | |
| 240 | 271 | Code: "empty", Title: d.name() + " is empty", | |
| 241 | 272 | Lines: []string{"no commits yet. push a branch to create one."}, | |
A internal/adapters/web/rules_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "encoding/json" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // The speculation rules prefetch the light pages and leave the heavy ones | |
| 14 | + | // (several Git reads each) to a click. | |
| 15 | + | func TestSpeculationRulesSkipHeavyPages(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 17 | + | raw, err := staticFS.ReadFile("static/rules.json") | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | var rules struct { | |
| 20 | + | Prefetch []struct { | |
| 21 | + | Where struct { | |
| 22 | + | And []struct { | |
| 23 | + | Href string `json:"href_matches"` | |
| 24 | + | Not *struct { | |
| 25 | + | Href string `json:"href_matches"` | |
| 26 | + | } `json:"not"` | |
| 27 | + | } `json:"and"` | |
| 28 | + | } `json:"where"` | |
| 29 | + | Eagerness string `json:"eagerness"` | |
| 30 | + | } `json:"prefetch"` | |
| 31 | + | } | |
| 32 | + | require.NoError(t, json.Unmarshal(raw, &rules)) | |
| 33 | + | require.Len(t, rules.Prefetch, 1) | |
| 34 | + | assert.Equal(t, "moderate", rules.Prefetch[0].Eagerness) | |
| 35 | + | ||
| 36 | + | var allowed string | |
| 37 | + | excluded := map[string]bool{} | |
| 38 | + | for _, c := range rules.Prefetch[0].Where.And { | |
| 39 | + | if c.Href != "" { | |
| 40 | + | allowed = c.Href | |
| 41 | + | } | |
| 42 | + | if c.Not != nil && c.Not.Href != "" { | |
| 43 | + | excluded[c.Not.Href] = true | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | assert.Equal(t, "/*", allowed) | |
| 47 | + | for _, p := range []string{ | |
| 48 | + | "/-/*", "/*/raw/*", "/*/tree", "/*/tree/*", "/*/blob/*", "/*/commit/*", | |
| 49 | + | "/*/commits", "/*/commits/*", "/*/compare/*", "/*/refs", "/*/merge-requests/*/since", | |
| 50 | + | } { | |
| 51 | + | assert.True(t, excluded[p], "%s is not prefetched", p) | |
| 52 | + | } | |
| 53 | + | for _, p := range []string{"/", "/*", "/*/merge-requests", "/*/merge-requests/*"} { | |
| 54 | + | assert.False(t, excluded[p], "%s stays prefetched", p) | |
| 55 | + | } | |
| 56 | + | } | |
M internal/adapters/web/session.go +18 -29
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | 18 | "bnema.dev/ermite/internal/clientlimit" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| 21 | 23 | // The owner's session on the web (ADR 0018). | |
| @@ -29,7 +31,7 @@ import ( | |||
| 29 | 31 | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | |
| 30 | 32 | // the origin of the public URL, and a valid session, except the login POST, | |
| 31 | 33 | // which needs the link instead. The routes read the cookie and never the | |
| 32 | - | // Authorization header; the API does the opposite. | |
| 34 | + | // Authorization header; the API does the opposite, it reads the header and no cookie. | |
| 33 | 35 | ||
| 34 | 36 | // CookieName is the session cookie. The __Host- prefix makes browsers accept | |
| 35 | 37 | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | |
| @@ -39,31 +41,6 @@ const CookieName = "__Host-ermite" | |||
| 39 | 41 | // loginPrefix is the path of login links, up to the nonce. | |
| 40 | 42 | const loginPrefix = "/-/login/" | |
| 41 | 43 | ||
| 42 | - | // ErrLinkRejected is what SessionAuth.Login returns for a link that is | |
| 43 | - | // unknown, expired or already used. | |
| 44 | - | var ErrLinkRejected = errors.New("login link rejected") | |
| 45 | - | ||
| 46 | - | // SessionAuth is the session side of the use cases, as the web needs it. | |
| 47 | - | type SessionAuth interface { | |
| 48 | - | // LoginLinkValid reports whether the link is pending, without using it. | |
| 49 | - | LoginLinkValid(nonce string) bool | |
| 50 | - | // Login uses the link and returns the session token and how long it | |
| 51 | - | // lasts, or ErrLinkRejected. | |
| 52 | - | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | |
| 53 | - | // Verify checks a session token. When it is valid it returns the context | |
| 54 | - | // to serve the request with, which carries the owner as the caller of the | |
| 55 | - | // API; when it is not, ok is false and err nil. An error means the check | |
| 56 | - | // could not be made (the request then runs as anonymous). | |
| 57 | - | Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // FailureLimiter counts failed logins per client, with the failed | |
| 61 | - | // authentications of the other routes. *clientlimit.Limiter fits. | |
| 62 | - | type FailureLimiter interface { | |
| 63 | - | Blocked(ctx context.Context, addr string) (bool, time.Duration) | |
| 64 | - | Fail(ctx context.Context, addr string) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | 44 | type signedInKey struct{} | |
| 68 | 45 | ||
| 69 | 46 | // SignedIn reports whether the request carries a valid owner session. The | |
| @@ -150,7 +127,19 @@ func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Reque | |||
| 150 | 127 | varyCookie(h) | |
| 151 | 128 | return r | |
| 152 | 129 | } | |
| 153 | - | return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{})) | |
| 130 | + | ctx = domain.ContextWithPrincipal(ctx, owner) | |
| 131 | + | return r.WithContext(context.WithValue(ctx, signedInKey{}, struct{}{})) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // principal is the caller of the request, the one every use case call carries: | |
| 135 | + | // the owner when the request has a valid session (withSession), anonymous | |
| 136 | + | // otherwise. | |
| 137 | + | func principal(ctx context.Context) domain.Principal { | |
| 138 | + | p, ok := domain.PrincipalFromContext(ctx) | |
| 139 | + | if !ok { | |
| 140 | + | return domain.Anonymous() | |
| 141 | + | } | |
| 142 | + | return p | |
| 154 | 143 | } | |
| 155 | 144 | ||
| 156 | 145 | // sessionStillValid checks again the cookie a long-lived request started | |
| @@ -275,12 +264,12 @@ func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { | |||
| 275 | 264 | nonce := r.PathValue("nonce") | |
| 276 | 265 | var token string | |
| 277 | 266 | var lifetime time.Duration | |
| 278 | - | err := ErrLinkRejected | |
| 267 | + | err := ports.ErrLinkRejected | |
| 279 | 268 | if validNonce(nonce) { | |
| 280 | 269 | token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce) | |
| 281 | 270 | } | |
| 282 | 271 | switch { | |
| 283 | - | case errors.Is(err, ErrLinkRejected): | |
| 272 | + | case errors.Is(err, ports.ErrLinkRejected): | |
| 284 | 273 | s.cfg.Limiter.Fail(ctx, addr) | |
| 285 | 274 | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | |
| 286 | 275 | return | |
M internal/adapters/web/session_test.go +79 -45
| @@ -13,30 +13,58 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 16 | 17 | "github.com/stretchr/testify/require" | |
| 17 | 18 | ||
| 18 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | 22 | ) | |
| 20 | 23 | ||
| 21 | - | // fakeSessions accepts one token. | |
| 22 | - | type fakeSessions struct { | |
| 23 | - | valid string | |
| 24 | - | err error | |
| 25 | - | used []string | |
| 24 | + | // nonceA is the one valid login link of the mocks. | |
| 25 | + | var nonceA = strings.Repeat("a", 43) | |
| 26 | + | ||
| 27 | + | // newVerifier is a WebSessionService that accepts the session token valid, checked | |
| 28 | + | // by verify when it is not nil, and that does not know any login link. A | |
| 29 | + | // call that is not expected fails the test. | |
| 30 | + | func newVerifier(t *testing.T, valid string, verify func(token string) (bool, error)) *mocks.WebSessionService { | |
| 31 | + | t.Helper() | |
| 32 | + | if verify == nil { | |
| 33 | + | verify = func(token string) (bool, error) { return token == valid, nil } | |
| 34 | + | } | |
| 35 | + | m := mocks.NewWebSessionService(t) | |
| 36 | + | m.EXPECT().Verify(mock.Anything, mock.Anything).RunAndReturn( | |
| 37 | + | func(_ context.Context, token string) (domain.Principal, bool, error) { | |
| 38 | + | ok, err := verify(token) | |
| 39 | + | if !ok || err != nil { | |
| 40 | + | return domain.Anonymous(), false, err | |
| 41 | + | } | |
| 42 | + | return domain.Owner(), true, nil | |
| 43 | + | }).Maybe() | |
| 44 | + | return m | |
| 26 | 45 | } | |
| 27 | 46 | ||
| 28 | - | func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) } | |
| 47 | + | // expectLogin makes nonceA the one link of m, answering with the token valid. | |
| 48 | + | func expectLogin(m *mocks.WebSessionService, valid string) { | |
| 49 | + | m.EXPECT().LoginLinkValid(mock.Anything).RunAndReturn(func(n string) bool { return n == nonceA }).Maybe() | |
| 50 | + | m.EXPECT().Login(mock.Anything, nonceA).Return(valid, time.Hour, nil).Maybe() | |
| 51 | + | m.EXPECT().Login(mock.Anything, mock.Anything).Return("", time.Duration(0), ports.ErrLinkRejected).Maybe() | |
| 52 | + | } | |
| 29 | 53 | ||
| 30 | - | func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) { | |
| 31 | - | f.used = append(f.used, n) | |
| 32 | - | if n != strings.Repeat("a", 43) { | |
| 33 | - | return "", 0, ErrLinkRejected | |
| 34 | - | } | |
| 35 | - | return f.valid, time.Hour, nil | |
| 54 | + | // newSessions is a WebSessionService with one session token and one login link. | |
| 55 | + | func newSessions(t *testing.T, valid string) *mocks.WebSessionService { | |
| 56 | + | t.Helper() | |
| 57 | + | m := newVerifier(t, valid, nil) | |
| 58 | + | expectLogin(m, valid) | |
| 59 | + | return m | |
| 36 | 60 | } | |
| 37 | 61 | ||
| 38 | - | func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | |
| 39 | - | return ctx, f.err == nil && tok == f.valid, f.err | |
| 62 | + | // newBrokenSessions is a WebSessionService whose check cannot be made. | |
| 63 | + | func newBrokenSessions(t *testing.T, valid string) *mocks.WebSessionService { | |
| 64 | + | t.Helper() | |
| 65 | + | m := newVerifier(t, valid, func(string) (bool, error) { return false, errors.New("kv down") }) | |
| 66 | + | expectLogin(m, valid) | |
| 67 | + | return m | |
| 40 | 68 | } | |
| 41 | 69 | ||
| 42 | 70 | type countingLimiter struct{ fails int } | |
| @@ -44,19 +72,18 @@ type countingLimiter struct{ fails int } | |||
| 44 | 72 | func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 } | |
| 45 | 73 | func (l *countingLimiter) Fail(context.Context, string) { l.fails++ } | |
| 46 | 74 | ||
| 47 | - | func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) { | |
| 75 | + | func newSessionServer(t *testing.T, sess ports.WebSessionService, publicURL string) (*Server, *countingLimiter) { | |
| 48 | 76 | t.Helper() | |
| 49 | 77 | lim := &countingLimiter{} | |
| 50 | - | srv, err := New(Config{ | |
| 51 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 52 | - | PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 53 | - | Sessions: sess, Limiter: lim, | |
| 78 | + | srv := newTestServer(t, Config{ | |
| 79 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 80 | + | PublicURL: publicURL, Sessions: sess, Limiter: lim, | |
| 54 | 81 | }) | |
| 55 | - | require.NoError(t, err) | |
| 56 | 82 | return srv, lim | |
| 57 | 83 | } | |
| 58 | 84 | ||
| 59 | 85 | func TestOriginOf(t *testing.T) { | |
| 86 | + | t.Parallel() | |
| 60 | 87 | for in, want := range map[string]string{ | |
| 61 | 88 | "https://forge.example": "https://forge.example", | |
| 62 | 89 | "https://Forge.Example/": "https://forge.example", | |
| @@ -75,6 +102,7 @@ func TestOriginOf(t *testing.T) { | |||
| 75 | 102 | } | |
| 76 | 103 | ||
| 77 | 104 | func TestValidNonce(t *testing.T) { | |
| 105 | + | t.Parallel() | |
| 78 | 106 | assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a")) | |
| 79 | 107 | for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} { | |
| 80 | 108 | assert.False(t, validNonce(n), n) | |
| @@ -84,7 +112,8 @@ func TestValidNonce(t *testing.T) { | |||
| 84 | 112 | // The gate: a POST needs the Origin of the public URL, whatever Host says, and | |
| 85 | 113 | // a session unless it is the login. | |
| 86 | 114 | func TestGate(t *testing.T) { | |
| 87 | - | srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |
| 115 | + | t.Parallel() | |
| 116 | + | srv, lim := newSessionServer(t, newSessions(t, "tok"), "https://forge.example") | |
| 88 | 117 | do := func(path, origin, cookie string) int { | |
| 89 | 118 | req := httptest.NewRequest(http.MethodPost, path, nil) | |
| 90 | 119 | req.Host = "forge.example" | |
| @@ -122,14 +151,14 @@ func TestGate(t *testing.T) { | |||
| 122 | 151 | // one the router matches, and on exactly loginPrefix + nonce. An encoded slash | |
| 123 | 152 | // must not turn another route into a "login". | |
| 124 | 153 | func TestGateLoginExemptionIsExact(t *testing.T) { | |
| 125 | - | sess := &fakeSessions{valid: "tok"} | |
| 154 | + | t.Parallel() | |
| 155 | + | sess := newVerifier(t, "tok", nil) // no Login is expected until the last request | |
| 126 | 156 | lim := &countingLimiter{} | |
| 127 | - | srv, err := New(Config{ | |
| 128 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 129 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 130 | - | PublicURL: "https://forge.example", Sessions: sess, Limiter: lim, | |
| 157 | + | srv := newTestServer(t, Config{ | |
| 158 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 159 | + | MergeRequests: mocks.NewMergeRequestService(t), | |
| 160 | + | Sessions: sess, Limiter: lim, | |
| 131 | 161 | }) | |
| 132 | - | require.NoError(t, err) | |
| 133 | 162 | post := func(target, cookie string) int { | |
| 134 | 163 | req := httptest.NewRequest(http.MethodPost, target, nil) | |
| 135 | 164 | req.Header.Set("Origin", "https://forge.example") | |
| @@ -140,7 +169,7 @@ func TestGateLoginExemptionIsExact(t *testing.T) { | |||
| 140 | 169 | srv.ServeHTTP(rec, req) | |
| 141 | 170 | return rec.Code | |
| 142 | 171 | } | |
| 143 | - | nonce := strings.Repeat("a", 43) | |
| 172 | + | nonce := nonceA | |
| 144 | 173 | for _, target := range []string{ | |
| 145 | 174 | "/-%2Flogin%2Fx/merge-requests/1/close", | |
| 146 | 175 | "/-%2flogin%2fx/merge-requests/1/close", | |
| @@ -159,25 +188,28 @@ func TestGateLoginExemptionIsExact(t *testing.T) { | |||
| 159 | 188 | } { | |
| 160 | 189 | assert.Equal(t, http.StatusForbidden, post(target, ""), target) | |
| 161 | 190 | } | |
| 162 | - | assert.Empty(t, sess.used, "no link was used") | |
| 191 | + | // No Login was expected so far: the mock fails the test on any call. | |
| 192 | + | sess.AssertNotCalled(t, "Login", mock.Anything, mock.Anything) | |
| 163 | 193 | ||
| 194 | + | sess.EXPECT().Login(mock.Anything, nonce).Return("tok", time.Hour, nil).Once() | |
| 164 | 195 | assert.Equal(t, http.StatusSeeOther, post("/-/login/"+nonce, ""), "a valid link still logs in") | |
| 165 | - | assert.Equal(t, []string{nonce}, sess.used) | |
| 166 | 196 | } | |
| 167 | 197 | ||
| 168 | 198 | // A session check that cannot be made fails closed (anonymous) and keeps the | |
| 169 | 199 | // cookie; a check that says no clears it. | |
| 170 | 200 | func TestWithSessionFailsClosed(t *testing.T) { | |
| 171 | - | broken := &fakeSessions{valid: "tok", err: errors.New("kv down")} | |
| 201 | + | t.Parallel() | |
| 202 | + | broken := newBrokenSessions(t, "tok") | |
| 172 | 203 | srv, _ := newSessionServer(t, broken, "https://forge.example") | |
| 173 | 204 | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 174 | 205 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |
| 175 | 206 | rec := httptest.NewRecorder() | |
| 176 | 207 | r := srv.withSession(rec, req) | |
| 177 | 208 | assert.False(t, SignedIn(r.Context())) | |
| 209 | + | assert.Equal(t, domain.Anonymous(), principal(r.Context())) | |
| 178 | 210 | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 179 | 211 | ||
| 180 | - | srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |
| 212 | + | srv, _ = newSessionServer(t, newSessions(t, "tok"), "https://forge.example") | |
| 181 | 213 | req = httptest.NewRequest(http.MethodGet, "/", nil) | |
| 182 | 214 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"}) | |
| 183 | 215 | rec = httptest.NewRecorder() | |
| @@ -187,14 +219,18 @@ func TestWithSessionFailsClosed(t *testing.T) { | |||
| 187 | 219 | ||
| 188 | 220 | req = httptest.NewRequest(http.MethodGet, "/", nil) | |
| 189 | 221 | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |
| 190 | - | assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context())) | |
| 222 | + | r = srv.withSession(httptest.NewRecorder(), req) | |
| 223 | + | assert.True(t, SignedIn(r.Context())) | |
| 224 | + | assert.Equal(t, domain.Owner(), principal(r.Context()), "a valid session is the owner") | |
| 225 | + | assert.Equal(t, domain.Anonymous(), principal(context.Background()), "no principal is anonymous") | |
| 191 | 226 | } | |
| 192 | 227 | ||
| 193 | 228 | // GO-006: the answer to a request with an invalid cookie is not cached and | |
| 194 | 229 | // varies on Cookie, and nothing later in the request undoes it: the page | |
| 195 | 230 | // writer, the raw file handler and the event stream keep it. | |
| 196 | 231 | func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |
| 197 | - | sess := &fakeSessions{valid: "tok"} | |
| 232 | + | t.Parallel() | |
| 233 | + | sess := newSessions(t, "tok") | |
| 198 | 234 | srv, _ := newSessionServer(t, sess, "https://forge.example") | |
| 199 | 235 | ||
| 200 | 236 | // withSession alone. | |
| @@ -207,7 +243,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||
| 207 | 243 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0") | |
| 208 | 244 | ||
| 209 | 245 | // A check that cannot be made keeps the cookie and the headers as they are. | |
| 210 | - | broken, _ := newSessionServer(t, &fakeSessions{valid: "tok", err: errors.New("kv down")}, "https://forge.example") | |
| 246 | + | broken, _ := newSessionServer(t, newBrokenSessions(t, "tok"), "https://forge.example") | |
| 211 | 247 | rec = httptest.NewRecorder() | |
| 212 | 248 | broken.withSession(rec, req) | |
| 213 | 249 | assert.Empty(t, rec.Header().Values("Cache-Control")) | |
| @@ -226,7 +262,7 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||
| 226 | 262 | rec := httptest.NewRecorder() | |
| 227 | 263 | srv.ServeHTTP(rec, req) | |
| 228 | 264 | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), target) | |
| 229 | - | assert.Equal(t, []string{"Cookie"}, rec.Header().Values("Vary"), target) | |
| 265 | + | assert.Equal(t, []string{"Cookie", "Accept-Encoding"}, rec.Header().Values("Vary"), target) | |
| 230 | 266 | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0", target) | |
| 231 | 267 | } | |
| 232 | 268 | ||
| @@ -251,23 +287,21 @@ func TestInvalidCookieAnswerIsNotCached(t *testing.T) { | |||
| 251 | 287 | ||
| 252 | 288 | // Without sessions configured there is no login route and no cookie is read. | |
| 253 | 289 | func TestNoSessionsNoRoutes(t *testing.T) { | |
| 254 | - | srv, err := New(Config{ | |
| 255 | - | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 256 | - | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 257 | - | }) | |
| 258 | - | require.NoError(t, err) | |
| 290 | + | t.Parallel() | |
| 291 | + | srv := newTestServer(t, Config{Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t)}) | |
| 259 | 292 | rec := httptest.NewRecorder() | |
| 260 | 293 | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | |
| 261 | 294 | assert.Equal(t, 404, rec.Code) | |
| 262 | 295 | ||
| 263 | - | _, err = New(Config{ | |
| 264 | - | Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 265 | - | PublicURL: "https://forge.example", Sessions: &fakeSessions{}, | |
| 296 | + | _, err := New(Config{ | |
| 297 | + | Repos: reposFailing(t, errors.New("x")), Browse: mocks.NewBrowseService(t), | |
| 298 | + | PublicURL: "https://forge.example", Sessions: mocks.NewWebSessionService(t), | |
| 266 | 299 | }) | |
| 267 | 300 | assert.Error(t, err, "sessions need a limiter") | |
| 268 | 301 | } | |
| 269 | 302 | ||
| 270 | 303 | func TestLogPathHidesLinks(t *testing.T) { | |
| 304 | + | t.Parallel() | |
| 271 | 305 | assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43))) | |
| 272 | 306 | assert.Equal(t, "/demo/tree", logPath("/demo/tree")) | |
| 273 | 307 | for _, p := range []string{"//-/login/secret", "/x/../-/login/secret", "/-/login/secret/more", "/-/./login/secret"} { | |
M internal/adapters/web/shell.templ +8 -8
| @@ -16,6 +16,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 16 | 16 | <link rel="icon" type="image/svg+xml" href={ templ.SafeURL(l.Assets.Icon) }/> | |
| 17 | 17 | <link rel="preload" href={ templ.SafeURL(l.Assets.Font) } as="font" type="font/woff2" crossorigin/> | |
| 18 | 18 | <link rel="stylesheet" href={ templ.SafeURL(l.Assets.CSS) }/> | |
| 19 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'/> | |
| 19 | 20 | <script src={ l.Assets.HTMX } defer></script> | |
| 20 | 21 | <script src={ l.Assets.Live } defer></script> | |
| 21 | 22 | <script src={ l.Assets.SSE } defer></script> | |
| @@ -92,34 +93,33 @@ templ Header(l Layout) { | |||
| 92 | 93 | } | |
| 93 | 94 | <div class="hdr-acts"> | |
| 94 | 95 | if l.Ref != "" { | |
| 95 | - | <button | |
| 96 | - | type="button" | |
| 96 | + | <a | |
| 97 | 97 | class="btn ref" | |
| 98 | - | aria-haspopup="dialog" | |
| 99 | - | aria-controls="panel-refs" | |
| 98 | + | href={ templ.SafeURL(repoHref(l.Repo, "refs")) } | |
| 99 | + | data-fire="refs" | |
| 100 | 100 | aria-keyshortcuts="b" | |
| 101 | 101 | title="switch branch or tag (b)" | |
| 102 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))" | |
| 103 | 102 | > | |
| 104 | 103 | <span class="ico i-branch" aria-hidden="true"></span> | |
| 105 | 104 | <span class="ref-name">{ l.Ref }</span> | |
| 106 | 105 | <span class="sha">{ l.Short }</span> | |
| 107 | - | </button> | |
| 106 | + | </a> | |
| 108 | 107 | } | |
| 109 | 108 | <button | |
| 110 | 109 | type="button" | |
| 111 | 110 | class="btn search" | |
| 111 | + | data-js | |
| 112 | 112 | aria-haspopup="dialog" | |
| 113 | 113 | aria-controls="panel-cmd" | |
| 114 | 114 | aria-keyshortcuts="Control+K :" | |
| 115 | 115 | title="go to a section, ref, file or repo (ctrl k or :)" | |
| 116 | - | hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))" | |
| 116 | + | data-fire="cmd" | |
| 117 | 117 | > | |
| 118 | 118 | <span class="ico i-search" aria-hidden="true"></span> | |
| 119 | 119 | <span class="search-label">Go to…</span> | |
| 120 | 120 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 121 | 121 | </button> | |
| 122 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 122 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 123 | 123 | if SignedIn(ctx) { | |
| 124 | 124 | <form class="owner" method="post" action="/-/logout"> | |
| 125 | 125 | <span class="owner-badge" title="signed in as the owner">owner</span> | |
M internal/adapters/web/shell_templ.go +82 -69 generated file: collapsed
M internal/adapters/web/static/css/10-base.css +2 -0
| @@ -70,6 +70,8 @@ | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | @layer utilities { | |
| 73 | + | /* what does nothing without scripts waits for ermite.js, which sets html.js */ | |
| 74 | + | html:not(.js) [data-js], html:not(.js) .filter { display: none; } | |
| 73 | 75 | .dim { color: var(--ink-dim); } | |
| 74 | 76 | .mute { color: var(--ink-mute); } | |
| 75 | 77 | .nowrap { white-space: nowrap; } | |
M internal/adapters/web/static/css/20-shell.css +6 -1
| @@ -169,7 +169,12 @@ | |||
| 169 | 169 | & > :empty { display: none; } | |
| 170 | 170 | & .hint-live { color: var(--red); } | |
| 171 | 171 | } | |
| 172 | - | .hint:not(:has(> :not(:empty))) { display: none; } | |
| 172 | + | /* empty, the badge is out of sight but stays a status region (display: none | |
| 173 | + | would drop it from the accessibility tree and its next message would not be | |
| 174 | + | announced) */ | |
| 175 | + | .hint:not(:has(> :not(:empty))) { | |
| 176 | + | width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); box-shadow: none; | |
| 177 | + | } | |
| 173 | 178 | ||
| 174 | 179 | @media (max-width: 1080px) { | |
| 175 | 180 | /* the rail goes under the content, in two columns while there is room */ | |
M internal/adapters/web/static/css/80-live.css +7 -4
| @@ -1,9 +1,12 @@ | |||
| 1 | 1 | @layer components { | |
| 2 | - | /* live updates: the banner of new commits, an empty slot takes no room */ | |
| 3 | - | #commits-head:empty { display: none; } | |
| 4 | - | .live-banner { | |
| 2 | + | /* live updates: a slot is a status region that stays in the page; its banner | |
| 3 | + | comes and goes as its content. Empty, it is out of the way but still in the | |
| 4 | + | accessibility tree (display: none would drop the region, and the next banner | |
| 5 | + | would not be announced). */ | |
| 6 | + | .live-slot:empty { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } | |
| 7 | + | .live-slot:not(:empty) { | |
| 5 | 8 | padding: var(--s3) var(--s4); font-weight: 700; | |
| 6 | 9 | background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s); | |
| 7 | 10 | } | |
| 8 | - | .live-banner a { color: var(--ink-hi); } | |
| 11 | + | .live-slot a { color: var(--ink-hi); } | |
| 9 | 12 | } | |
M internal/adapters/web/static/js/ermite.js +310 -17
| @@ -1,55 +1,322 @@ | |||
| 1 | - | // ermite.js: the one thing hx-live cannot do is a global key *sequence* state machine | |
| 2 | - | // ("g" then "h"), nor ignore keystrokes aimed at inputs before any element is focused. | |
| 3 | - | // It only translates keys into DOM events named "k-<name>" on <body>; every reaction | |
| 4 | - | // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live. | |
| 1 | + | // ermite.js: the behaviour of the pages that htmx and hx-live do not give. | |
| 2 | + | // | |
| 3 | + | // 1. Keys. A global key *sequence* state machine ("g" then "h") and the | |
| 4 | + | // keystrokes aimed at inputs cannot be declared with hx-on. The keydown | |
| 5 | + | // handler only translates keys into DOM events named "k-<name>" on <body>; | |
| 6 | + | // keys.templ binds each of them (hx-on, one line each) to a function of | |
| 7 | + | // window.ermite below, which does the work: row cursor, dialogs, filters, | |
| 8 | + | // diff layout, permalink. | |
| 9 | + | // 2. Clicks on [data-fire] elements fire the same events (header buttons). | |
| 10 | + | // 3. ermiteLive applies the fragments of the event stream (see events.go). | |
| 11 | + | // 4. The file of the diff in view is marked in the table of contents. | |
| 12 | + | // 5. A handler that fails is shown in the hint badge. | |
| 5 | 13 | (() => { | |
| 14 | + | // Marks the page as scripted: what does nothing without scripts (the filters, | |
| 15 | + | // the "go to" and help buttons) is hidden until this class is set (see the | |
| 16 | + | // utilities of 10-base.css). | |
| 17 | + | document.documentElement.classList.add('js'); | |
| 18 | + | ||
| 19 | + | const $ = (sel, root = document) => root.querySelector(sel); | |
| 20 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 21 | + | const body = () => document.body; | |
| 22 | + | ||
| 23 | + | // flash shows a message in the hint badge (body[data-flash], a status | |
| 24 | + | // region), then clears it unless another message came in the meantime. | |
| 25 | + | let flashTimer = 0; | |
| 26 | + | const flash = (msg, ms = 2000) => { | |
| 27 | + | body().dataset.flash = msg; | |
| 28 | + | clearTimeout(flashTimer); | |
| 29 | + | flashTimer = setTimeout(() => (body().dataset.flash = ''), ms); | |
| 30 | + | }; | |
| 31 | + | ||
| 32 | + | const visibleRows = () => $$('[data-rows] > :not([hidden])'); | |
| 33 | + | // setCursor puts data-cursor on el and takes it from the others. | |
| 34 | + | const setCursor = (el, others) => { | |
| 35 | + | for (const o of others) if (o !== el) o.removeAttribute('data-cursor'); | |
| 36 | + | el.setAttribute('data-cursor', ''); | |
| 37 | + | }; | |
| 38 | + | const scrollPage = (to) => { | |
| 39 | + | scrollTo(0, to); | |
| 40 | + | $('#main')?.scrollTo(0, to); | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | const ermite = { | |
| 44 | + | fire(name) { | |
| 45 | + | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 46 | + | }, | |
| 47 | + | ||
| 48 | + | // Row cursor: j / k. | |
| 49 | + | move(d) { | |
| 50 | + | if ($('dialog[open]')) return; | |
| 51 | + | const rows = visibleRows(); | |
| 52 | + | if (!rows.length) { | |
| 53 | + | scrollBy(0, d * 180); | |
| 54 | + | $('#main')?.scrollBy(0, d * 180); | |
| 55 | + | return; | |
| 56 | + | } | |
| 57 | + | const i = rows.findIndex((r) => r.hasAttribute('data-cursor')); | |
| 58 | + | const j = Math.max(0, Math.min(rows.length - 1, i + d)); | |
| 59 | + | setCursor(rows[j], $$('[data-rows] > [data-cursor]')); | |
| 60 | + | rows[j].scrollIntoView({ block: 'nearest' }); | |
| 61 | + | const mark = j + 1 + '/' + rows.length; | |
| 62 | + | body().dataset.nav = mark; | |
| 63 | + | setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500); | |
| 64 | + | }, | |
| 65 | + | // gg / G: first or last row, or the top or bottom of the page. | |
| 66 | + | edge(last) { | |
| 67 | + | const rows = visibleRows(); | |
| 68 | + | const row = last ? rows.at(-1) : rows[0]; | |
| 69 | + | if (!row) return scrollPage(last ? 1e9 : 0); | |
| 70 | + | setCursor(row, $$('[data-rows] > [data-cursor]')); | |
| 71 | + | row.scrollIntoView({ block: 'nearest' }); | |
| 72 | + | }, | |
| 73 | + | open() { | |
| 74 | + | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | |
| 75 | + | }, | |
| 76 | + | focusFilter() { | |
| 77 | + | $('[data-filter]')?.focus(); | |
| 78 | + | }, | |
| 79 | + | go(sel) { | |
| 80 | + | $(sel)?.click(); | |
| 81 | + | }, | |
| 82 | + | tab(key) { | |
| 83 | + | $(`.tabs [data-key=${key}]`)?.click(); | |
| 84 | + | }, | |
| 85 | + | ||
| 86 | + | // Dialogs. | |
| 87 | + | closeDialogs() { | |
| 88 | + | for (const d of $$('dialog[open]')) d.close(); | |
| 89 | + | }, | |
| 90 | + | dialog(id, focusId) { | |
| 91 | + | const d = document.getElementById(id); | |
| 92 | + | if (!d) return; | |
| 93 | + | ermite.closeDialogs(); | |
| 94 | + | d.showModal(); | |
| 95 | + | if (focusId) document.getElementById(focusId)?.focus(); | |
| 96 | + | }, | |
| 97 | + | ||
| 98 | + | // filter hides the rows that do not contain the text of an input. The | |
| 99 | + | // input says which rows (data-filter, or data-pick for a list without a | |
| 100 | + | // cursor) and, optionally, the groups to hide when they have no row left | |
| 101 | + | // (data-groups). The cursor goes to the first row left. | |
| 102 | + | filter(input) { | |
| 103 | + | const rows = ermite.hide(input); | |
| 104 | + | if (input.dataset.filter) { | |
| 105 | + | const first = rows.find((r) => !r.hidden); | |
| 106 | + | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |
| 107 | + | } | |
| 108 | + | }, | |
| 109 | + | // hide applies the text of a filter input to its rows and groups, without | |
| 110 | + | // moving the cursor, and returns the rows. | |
| 111 | + | hide(input) { | |
| 112 | + | const t = input.value.toLowerCase(); | |
| 113 | + | // A bare data-filter marks a field with its own search (the file finder | |
| 114 | + | // of the Files view): there are no rows to hide. | |
| 115 | + | const sel = input.dataset.filter || input.dataset.pick; | |
| 116 | + | if (!sel) return []; | |
| 117 | + | const rows = $$(sel); | |
| 118 | + | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | |
| 119 | + | if (input.dataset.groups) { | |
| 120 | + | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | |
| 121 | + | } | |
| 122 | + | return rows; | |
| 123 | + | }, | |
| 124 | + | // The "go to" palette. | |
| 125 | + | cmdFilter(input) { | |
| 126 | + | const t = input.value.toLowerCase().trim(); | |
| 127 | + | // The file hits the finder adds (li.finder-hit, files.js) have no data-k: | |
| 128 | + | // the finder owns them. | |
| 129 | + | const items = $$('#cmd-list > li[data-k]'); | |
| 130 | + | for (const li of items) li.hidden = !li.dataset.k.includes(t); | |
| 131 | + | const first = items.find((li) => !li.hidden); | |
| 132 | + | if (first) setCursor(first, $$('#cmd-list > li')); | |
| 133 | + | }, | |
| 134 | + | cmdKey(e) { | |
| 135 | + | const vis = $$('#cmd-list > li:not([hidden])'); | |
| 136 | + | const i = vis.findIndex((li) => li.hasAttribute('data-cursor')); | |
| 137 | + | const step = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0; | |
| 138 | + | if (step) { | |
| 139 | + | e.preventDefault(); | |
| 140 | + | const n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; | |
| 141 | + | if (n) { | |
| 142 | + | setCursor(n, $$('#cmd-list > li')); | |
| 143 | + | n.scrollIntoView({ block: 'nearest' }); | |
| 144 | + | } | |
| 145 | + | } else if (e.key === 'Enter') { | |
| 146 | + | e.preventDefault(); | |
| 147 | + | vis[Math.max(i, 0)]?.querySelector('a')?.click(); | |
| 148 | + | } | |
| 149 | + | }, | |
| 150 | + | ||
| 151 | + | // Diff. | |
| 152 | + | layout(mode) { | |
| 153 | + | const el = $('[data-diff]'); | |
| 154 | + | if (!el) return; | |
| 155 | + | el.dataset.diff = mode; | |
| 156 | + | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | |
| 157 | + | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | |
| 158 | + | }, | |
| 159 | + | collapse() { | |
| 160 | + | const files = $$('[data-file]'); | |
| 161 | + | const open = !files.some((d) => d.open); | |
| 162 | + | for (const d of files) d.open = open; | |
| 163 | + | }, | |
| 164 | + | nextNote() { | |
| 165 | + | const notes = $$('#diffs .cmt'); | |
| 166 | + | if (!notes.length) return; | |
| 167 | + | const i = notes.findIndex((n) => n.getBoundingClientRect().top > innerHeight / 3); | |
| 168 | + | const n = notes[i < 0 ? 0 : i]; | |
| 169 | + | const d = n.closest('details'); | |
| 170 | + | if (d) d.open = true; | |
| 171 | + | n.scrollIntoView({ block: 'center' }); | |
| 172 | + | }, | |
| 173 | + | ||
| 174 | + | // y: pin the URL to the commit and copy it. | |
| 175 | + | async permalink() { | |
| 176 | + | const b = body().dataset; | |
| 177 | + | if (!b.sha) return flash('no ref to pin on this page'); | |
| 178 | + | const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| 179 | + | // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>. | |
| 180 | + | const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname; | |
| 181 | + | if (path === location.pathname) return flash('this page has no ref to pin'); | |
| 182 | + | history.replaceState(null, '', path + location.hash); | |
| 183 | + | try { | |
| 184 | + | await navigator.clipboard.writeText(location.href); | |
| 185 | + | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | |
| 186 | + | } catch { | |
| 187 | + | flash('permalink in the address bar (copy refused)'); | |
| 188 | + | } | |
| 189 | + | }, | |
| 190 | + | ||
| 191 | + | // The [copy] button of a clone line: the label says "Copied" for 1.5 s, | |
| 192 | + | // and so does the accessible name; the hint badge (a status region) | |
| 193 | + | // announces it. | |
| 194 | + | async copy(btn) { | |
| 195 | + | const line = btn.closest('.clone-line')?.querySelector('code')?.textContent ?? ''; | |
| 196 | + | btn.dataset.idleName ||= btn.getAttribute('aria-label') || ''; | |
| 197 | + | let ok = true; | |
| 198 | + | try { await navigator.clipboard.writeText(line); } catch { ok = false; } | |
| 199 | + | btn.textContent = ok ? 'Copied' : 'Copy failed'; | |
| 200 | + | btn.setAttribute('aria-label', (ok ? 'copied: ' : 'copy failed: ') + line); | |
| 201 | + | flash(ok ? 'copied' : 'copy failed', 1500); | |
| 202 | + | clearTimeout(btn._restore); | |
| 203 | + | btn._restore = setTimeout(() => { | |
| 204 | + | btn.textContent = btn.dataset.label; | |
| 205 | + | btn.setAttribute('aria-label', btn.dataset.idleName); | |
| 206 | + | }, 1500); | |
| 207 | + | }, | |
| 208 | + | }; | |
| 209 | + | window.ermite = ermite; | |
| 210 | + | ||
| 211 | + | // 1. Keys. | |
| 6 | 212 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 7 | 213 | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | |
| 8 | - | const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' }; | |
| 214 | + | // "g t" is the key "t": the tabs have one event each. | |
| 215 | + | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | |
| 9 | 216 | let pending = 0; | |
| 10 | - | const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 217 | + | let pendingTimer = 0; | |
| 11 | 218 | addEventListener('keydown', (e) => { | |
| 12 | 219 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | |
| 13 | 220 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | |
| 14 | 221 | // field keeps ctrl k (kill to end of line on macOS). | |
| 15 | - | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return fire('cmd'); } | |
| 222 | + | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } | |
| 16 | 223 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 17 | - | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return fire('esc'); } | |
| 224 | + | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); } | |
| 18 | 225 | if (typing) return; | |
| 19 | 226 | // An open dialog takes only the keys that move between dialogs: the page | |
| 20 | 227 | // behind it does not react (scripted clicks would go through inert). | |
| 21 | - | if (document.querySelector('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | |
| 228 | + | if ($('dialog[open]') && !['?', 'b', ':', 'f', 'Enter'].includes(e.key)) { pending = 0; return; } | |
| 22 | 229 | if (e.key === 'Enter' && e.target.closest('a, button, summary')) return; | |
| 23 | 230 | if (pending) { | |
| 24 | 231 | const name = goto[e.key]; | |
| 25 | 232 | pending = 0; | |
| 26 | - | if (name) { e.preventDefault(); fire(name); } | |
| 233 | + | if (name) { e.preventDefault(); ermite.fire(name); } | |
| 27 | 234 | return; | |
| 28 | 235 | } | |
| 29 | - | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | |
| 236 | + | if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; } | |
| 30 | 237 | const name = single[e.key]; | |
| 31 | 238 | if (!name) return; | |
| 32 | - | if (e.key !== 'Enter' || !document.querySelector('dialog[open]')) e.preventDefault(); | |
| 33 | - | fire(name); | |
| 239 | + | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); | |
| 240 | + | ermite.fire(name); | |
| 241 | + | }); | |
| 242 | + | ||
| 243 | + | // 2. A [data-fire] element does what the key of that name does (the header | |
| 244 | + | // buttons, the diff layout links). The link to the refs page works without | |
| 245 | + | // scripts and is upgraded here to open the dialog; a click with a modifier | |
| 246 | + | // keeps its meaning (new tab). | |
| 247 | + | addEventListener('click', (e) => { | |
| 248 | + | const el = e.target.closest?.('[data-fire]'); | |
| 249 | + | if (!el || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |
| 250 | + | e.preventDefault(); | |
| 251 | + | ermite.fire(el.dataset.fire); | |
| 34 | 252 | }); | |
| 253 | + | for (const a of $$('a[data-fire="refs"]')) { | |
| 254 | + | a.setAttribute('aria-haspopup', 'dialog'); | |
| 255 | + | a.setAttribute('aria-controls', 'panel-refs'); | |
| 256 | + | } | |
| 257 | + | ||
| 35 | 258 | // In the app shell the page does not scroll, <main> does: give it focus so arrows, | |
| 36 | 259 | // PageUp/PageDown and space scroll the content without a click first. | |
| 37 | 260 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |
| 261 | + | ||
| 262 | + | // 4. The table of contents of a diff marks the file in view: the last file | |
| 263 | + | // in the top third of the window, else the last file whose top is above that | |
| 264 | + | // line (the first file when none is). One observer tracks the files in the | |
| 265 | + | // band; it reports only when a file enters or leaves it, so a scroll costs | |
| 266 | + | // nothing in between, and a jump that skips the band is caught by the scan. | |
| 267 | + | const files = $$('.dfile'); | |
| 268 | + | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | |
| 269 | + | if (files.length && links.size && 'IntersectionObserver' in window) { | |
| 270 | + | const inBand = new Set(); | |
| 271 | + | let current = null; | |
| 272 | + | const mark = () => { | |
| 273 | + | const f = files.findLast((x) => inBand.has(x)) | |
| 274 | + | ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3) | |
| 275 | + | ?? files[0]; | |
| 276 | + | if (f === current) return; | |
| 277 | + | links.get(current?.id)?.removeAttribute('aria-current'); | |
| 278 | + | links.get(f.id)?.setAttribute('aria-current', 'true'); | |
| 279 | + | current = f; | |
| 280 | + | }; | |
| 281 | + | const io = new IntersectionObserver((entries) => { | |
| 282 | + | for (const e of entries) { | |
| 283 | + | if (e.isIntersecting) inBand.add(e.target); | |
| 284 | + | else inBand.delete(e.target); | |
| 285 | + | } | |
| 286 | + | mark(); | |
| 287 | + | }, { rootMargin: '0px 0px -67% 0px' }); | |
| 288 | + | for (const f of files) io.observe(f); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // 5. A failure of a handler or of a request (htmx logs it to the console | |
| 292 | + | // already) is shown, so that a key that does nothing is not a mystery. | |
| 293 | + | addEventListener('htmx:error', (e) => { | |
| 294 | + | if (e.target.closest?.('.hint-live')) return; // the stream says so itself ("live updates paused") | |
| 295 | + | const err = e.detail?.error; | |
| 296 | + | flash('something went wrong' + (err?.message ? ': ' + err.message : ''), 4000); | |
| 297 | + | }); | |
| 38 | 298 | })(); | |
| 39 | 299 | ||
| 40 | 300 | // ermiteLive applies the fragments of the event stream (/-/events, see | |
| 41 | 301 | // internal/adapters/web/events.go). A fragment is a top-level element with an | |
| 42 | 302 | // id: it replaces the element of the page that has the same id. A fragment | |
| 43 | 303 | // marked data-gone removes it; a row of a repository the home page does not | |
| 44 | - | // list yet is added at the top of the list. A gap, which says the page may | |
| 45 | - | // have missed events or that what it shows changed, reloads the page. | |
| 304 | + | // list yet is added at the top of the list. A slot (data-slot: the place of a | |
| 305 | + | // banner, a persistent status region) keeps its element and only gets the | |
| 306 | + | // children of the fragment, so that screen readers announce the change. A gap, | |
| 307 | + | // which says the page may have missed events or that what it shows changed, | |
| 308 | + | // reloads the page. A stream the server refuses on reconnection (the | |
| 309 | + | // repository is gone, the session ended) makes the page reload. | |
| 46 | 310 | window.ermiteLive = (() => { | |
| 47 | 311 | let reloading = false; | |
| 312 | + | let stopped = false; | |
| 48 | 313 | return { | |
| 49 | 314 | swap(html) { | |
| 50 | 315 | const t = document.createElement('template'); | |
| 51 | 316 | t.innerHTML = html; | |
| 52 | - | for (const el of t.content.children) { | |
| 317 | + | const cursor = document.querySelector('[data-rows] > [data-cursor]'); | |
| 318 | + | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | |
| 319 | + | for (const el of [...t.content.children]) { | |
| 53 | 320 | const old = el.id && document.getElementById(el.id); | |
| 54 | 321 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | |
| 55 | 322 | if (!old) { | |
| @@ -57,16 +324,42 @@ window.ermiteLive = (() => { | |||
| 57 | 324 | if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); } | |
| 58 | 325 | continue; | |
| 59 | 326 | } | |
| 327 | + | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | |
| 60 | 328 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 61 | - | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |
| 62 | 329 | old.replaceWith(el); | |
| 63 | 330 | window.htmx?.process(el); | |
| 64 | 331 | } | |
| 332 | + | // The rows may have changed under an active filter: apply it again and | |
| 333 | + | // keep the cursor on its row while that row is still shown. | |
| 334 | + | for (const input of document.querySelectorAll('input[data-filter]')) { | |
| 335 | + | if (input.value) window.ermite?.hide(input); | |
| 336 | + | } | |
| 337 | + | if (!cursorKey) return; | |
| 338 | + | const rows = [...document.querySelectorAll('[data-rows] > *')]; | |
| 339 | + | const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey)); | |
| 340 | + | if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back); | |
| 65 | 341 | }, | |
| 66 | 342 | reload() { | |
| 67 | 343 | if (reloading) return; | |
| 68 | 344 | reloading = true; | |
| 69 | 345 | setTimeout(() => location.reload(), 300); | |
| 70 | 346 | }, | |
| 347 | + | // error handles htmx:sse:error (detail.status is the status of a refused | |
| 348 | + | // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by | |
| 349 | + | // itself, and the fetched page says what is the matter (or has no stream). | |
| 350 | + | // A page that reloaded for this a moment ago and still gets refused would | |
| 351 | + | // loop: it stops following the stream instead (see gate). | |
| 352 | + | error(status) { | |
| 353 | + | if (status !== 404 && status !== 401) return; | |
| 354 | + | let last = 0; | |
| 355 | + | try { last = +sessionStorage.getItem('ermite-live-reload') || 0; } catch { /* no storage: no guard */ } | |
| 356 | + | if (Date.now() - last < 15000) { stopped = true; return; } | |
| 357 | + | try { sessionStorage.setItem('ermite-live-reload', String(Date.now())); } catch { /* idem */ } | |
| 358 | + | this.reload(); | |
| 359 | + | }, | |
| 360 | + | // gate is htmx:sse:before:connection: once stopped, no more attempts. | |
| 361 | + | gate(event) { | |
| 362 | + | if (stopped) event.preventDefault(); | |
| 363 | + | }, | |
| 71 | 364 | }; | |
| 72 | 365 | })(); | |
M internal/adapters/web/static/rules.json +9 -0
| @@ -7,6 +7,15 @@ | |||
| 7 | 7 | { "href_matches": "/*" }, | |
| 8 | 8 | { "not": { "href_matches": "/-/*" } }, | |
| 9 | 9 | { "not": { "href_matches": "/*/raw/*" } }, | |
| 10 | + | { "not": { "href_matches": "/*/tree" } }, | |
| 11 | + | { "not": { "href_matches": "/*/tree/*" } }, | |
| 12 | + | { "not": { "href_matches": "/*/blob/*" } }, | |
| 13 | + | { "not": { "href_matches": "/*/commit/*" } }, | |
| 14 | + | { "not": { "href_matches": "/*/commits" } }, | |
| 15 | + | { "not": { "href_matches": "/*/commits/*" } }, | |
| 16 | + | { "not": { "href_matches": "/*/compare/*" } }, | |
| 17 | + | { "not": { "href_matches": "/*/refs" } }, | |
| 18 | + | { "not": { "href_matches": "/*/merge-requests/*/since" } }, | |
| 10 | 19 | { "not": { "selector_matches": "[rel~=nofollow]" } } | |
| 11 | 20 | ] | |
| 12 | 21 | }, | |
M internal/adapters/web/summary.go +53 -52
| @@ -7,15 +7,16 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "html" | |
| 9 | 9 | "html/template" | |
| 10 | + | "io" | |
| 10 | 11 | "net/http" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 14 | - | "connectrpc.com/connect" | |
| 15 | 15 | "github.com/a-h/templ" | |
| 16 | 16 | "github.com/bnema/zerowrap" | |
| 17 | 17 | ||
| 18 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 21 | 22 | // maxReadmeBytes is the largest README that is rendered in the page; a | |
| @@ -40,7 +41,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 40 | 41 | l, st := s.emptyState(ctx, d) | |
| 41 | 42 | return State(l, st), http.StatusOK, nil | |
| 42 | 43 | } | |
| 43 | - | ref := d.head.GetShortName() | |
| 44 | + | ref := d.head.ShortName | |
| 44 | 45 | now := s.cfg.Now() | |
| 45 | 46 | ||
| 46 | 47 | p := SummaryPage{ | |
| @@ -48,16 +49,15 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 48 | 49 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | - | commits, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 52 | - | Repo: d.name(), Revision: d.head.GetCommitId(), PageSize: recentCommits, | |
| 53 | - | })) | |
| 52 | + | commits, err := s.commitList(ctx, d.repo.Name, d.head.CommitID.String(), "", recentCommits, "") | |
| 54 | 53 | if err != nil { | |
| 55 | 54 | return nil, 0, err | |
| 56 | 55 | } | |
| 57 | 56 | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 58 | - | for i, c := range commits.Msg.GetCommits() { | |
| 57 | + | for i, c := range commits.Commits { | |
| 58 | + | id := c.ID.String() | |
| 59 | 59 | row := commitRow(d.name(), c, now) | |
| 60 | - | row.Branches, row.Tags, row.Cursor = branchesAt[c.GetId()], tagsAt[c.GetId()], i == 0 | |
| 60 | + | row.Branches, row.Tags, row.Cursor = branchesAt[id], tagsAt[id], i == 0 | |
| 61 | 61 | p.Recent = append(p.Recent, row) | |
| 62 | 62 | } | |
| 63 | 63 | ||
| @@ -74,7 +74,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component, | |||
| 74 | 74 | l.Title = d.name() + " · " + s.instance | |
| 75 | 75 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | |
| 76 | 76 | l.HasRail = true | |
| 77 | - | l.Lede = d.repo.GetDescription() | |
| 77 | + | l.Lede = d.repo.Description | |
| 78 | 78 | return Summary(l, p), http.StatusOK, nil | |
| 79 | 79 | } | |
| 80 | 80 | ||
| @@ -90,7 +90,7 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||
| 90 | 90 | } | |
| 91 | 91 | lt := &LatestTag{Count: n, RefsHref: repoHref(d.name(), "refs")} | |
| 92 | 92 | if n <= maxTagDates { | |
| 93 | - | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.name(), d.tags)); name != "" { | |
| 93 | + | if name, c := latestTag(d.tags, s.commitsOf(ctx, d.repo.Name, d.tags)); name != "" { | |
| 94 | 94 | lt.Name, lt.Href = name, repoHref(d.name(), "tree", name) | |
| 95 | 95 | lt.Subject, lt.When, lt.WhenAt = c.subject, c.when, c.whenAt | |
| 96 | 96 | } | |
| @@ -102,33 +102,33 @@ func (s *Server) latest(ctx context.Context, d *repoData) *LatestTag { | |||
| 102 | 102 | func (s *Server) facts(d *repoData, now time.Time) []Fact { | |
| 103 | 103 | fs := []Fact{ | |
| 104 | 104 | lastPushFact(d.repo, now), | |
| 105 | - | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 105 | + | {Key: "branch", Val: d.head.ShortName + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 106 | 106 | } | |
| 107 | - | if c := d.repo.GetCreatedAt(); c.IsValid() { | |
| 108 | - | fs = append(fs, Fact{Key: "created", Val: absolute(c.AsTime()), Time: rfc3339(c.AsTime())}) | |
| 107 | + | if c := d.repo.CreatedAt; !c.IsZero() { | |
| 108 | + | fs = append(fs, Fact{Key: "created", Val: absolute(c), Time: rfc3339(c)}) | |
| 109 | 109 | } | |
| 110 | 110 | return fs | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 113 | // lastPushFact is the "last push" fact of a repository; the live updates | |
| 114 | 114 | // render it again. | |
| 115 | - | func lastPushFact(r *pb.Repo, now time.Time) Fact { | |
| 116 | - | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()} | |
| 117 | - | if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 { | |
| 118 | - | last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime()) | |
| 119 | - | last.Time = rfc3339(t.AsTime()) | |
| 115 | + | func lastPushFact(r ports.RepoView, now time.Time) Fact { | |
| 116 | + | last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.Name.String()} | |
| 117 | + | if t := r.PushedAt; !t.IsZero() { | |
| 118 | + | last.Val = ago(now, t) + " · " + absolute(t) | |
| 119 | + | last.Time = rfc3339(t) | |
| 120 | 120 | } | |
| 121 | 121 | return last | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | 124 | // latestTag is the tag whose commit is the newest. | |
| 125 | - | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 125 | + | func latestTag(tags []domain.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 126 | 126 | var name string | |
| 127 | 127 | var best refCommit | |
| 128 | 128 | for _, t := range tags { | |
| 129 | - | c, ok := commits[t.GetCommitId()] | |
| 129 | + | c, ok := commits[t.CommitID.String()] | |
| 130 | 130 | if ok && c.short != "" && (name == "" || c.at > best.at) { | |
| 131 | - | name, best = t.GetShortName(), c | |
| 131 | + | name, best = t.ShortName, c | |
| 132 | 132 | } | |
| 133 | 133 | } | |
| 134 | 134 | return name, best | |
| @@ -142,27 +142,31 @@ type renderedReadme struct { | |||
| 142 | 142 | ||
| 143 | 143 | // readme finds the README at the root of the tree of the head commit, reads | |
| 144 | 144 | // it and renders it. It is nil when the repository has none. Rendering | |
| 145 | - | // problems degrade to plain text; only API failures are errors. | |
| 145 | + | // problems degrade to plain text; only use case failures are errors. | |
| 146 | 146 | func (s *Server) readme(ctx context.Context, d *repoData, ref string) (*renderedReadme, error) { | |
| 147 | - | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: d.head.GetCommitId()})) | |
| 147 | + | rev := d.head.CommitID.String() | |
| 148 | + | tree, err := s.getTree(ctx, d.repo.Name, rev, "", false) | |
| 148 | 149 | if err != nil { | |
| 149 | 150 | return nil, err | |
| 150 | 151 | } | |
| 151 | - | return s.readmeIn(ctx, d.name(), d.head.GetCommitId(), ref, "", tree.Msg.GetEntries()) | |
| 152 | + | return s.readmeIn(ctx, d.repo.Name, rev, ref, "", tree.Entries) | |
| 152 | 153 | } | |
| 153 | 154 | ||
| 154 | 155 | // readmeIn picks the README among the entries of the directory dir ("" for | |
| 155 | 156 | // the root) of the commit rev, reads it and renders it. Links in it are | |
| 156 | 157 | // relative to dir, on the ref ref. It is nil when the directory has none. | |
| 157 | - | func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entries []*pb.TreeEntry) (*renderedReadme, error) { | |
| 158 | - | var best *pb.TreeEntry | |
| 158 | + | // | |
| 159 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks the README among the entries of a directory to show; display only | |
| 160 | + | func (s *Server) readmeIn(ctx context.Context, repo domain.RepoName, rev, ref, dir string, entries []domain.TreeEntry) (*renderedReadme, error) { | |
| 161 | + | var best *domain.TreeEntry | |
| 159 | 162 | rank := len(readmeNames) | |
| 160 | - | for _, e := range entries { | |
| 161 | - | if e.GetKind() != pb.EntryKind_ENTRY_KIND_FILE && e.GetKind() != pb.EntryKind_ENTRY_KIND_EXECUTABLE { | |
| 163 | + | for i := range entries { | |
| 164 | + | e := &entries[i] | |
| 165 | + | if e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable { | |
| 162 | 166 | continue | |
| 163 | 167 | } | |
| 164 | 168 | for i, n := range readmeNames { | |
| 165 | - | if i < rank && strings.EqualFold(e.GetName(), n) { | |
| 169 | + | if i < rank && strings.EqualFold(e.Name, n) { | |
| 166 | 170 | best, rank = e, i | |
| 167 | 171 | } | |
| 168 | 172 | } | |
| @@ -170,54 +174,51 @@ func (s *Server) readmeIn(ctx context.Context, repo, rev, ref, dir string, entri | |||
| 170 | 174 | if best == nil { | |
| 171 | 175 | return nil, nil | |
| 172 | 176 | } | |
| 173 | - | rd := &Readme{Name: best.GetName()} | |
| 174 | - | if best.GetSize() > maxReadmeBytes { | |
| 175 | - | rd.Note = best.GetName() + " is too large to show here (" + humanSize(best.GetSize()) + ")." | |
| 177 | + | rd := &Readme{Name: best.Name} | |
| 178 | + | if best.Size > maxReadmeBytes { | |
| 179 | + | rd.Note = best.Name + " is too large to show here (" + humanSize(best.Size) + ")." | |
| 176 | 180 | return &renderedReadme{readme: rd}, nil | |
| 177 | 181 | } | |
| 178 | - | body, binary, err := s.readBlob(ctx, repo, rev, best.GetPath(), maxReadmeBytes) | |
| 182 | + | body, binary, err := s.readBlob(ctx, repo, rev, best.Path, maxReadmeBytes) | |
| 179 | 183 | if err != nil { | |
| 180 | 184 | return nil, err | |
| 181 | 185 | } | |
| 182 | 186 | if binary { | |
| 183 | - | rd.Note = best.GetName() + " is not text." | |
| 187 | + | rd.Note = best.Name + " is not text." | |
| 184 | 188 | return &renderedReadme{readme: rd}, nil | |
| 185 | 189 | } | |
| 186 | - | name := strings.ToLower(best.GetName()) | |
| 190 | + | name := strings.ToLower(best.Name) | |
| 187 | 191 | if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, ".markdown") { | |
| 188 | - | h, outline, err := s.md.render(body, repo, ref, dir) | |
| 192 | + | h, outline, err := s.md.render(body, repo.String(), ref, dir) | |
| 189 | 193 | if err == nil { | |
| 190 | 194 | rd.HTML = h | |
| 191 | 195 | return &renderedReadme{readme: rd, outline: outline}, nil | |
| 192 | 196 | } | |
| 193 | 197 | log := zerowrap.FromCtx(ctx) | |
| 194 | - | log.Warn().Err(err).Str("repo", repo).Msg("render README, showing plain text") | |
| 198 | + | log.Warn().Err(err).Str("repo", repo.String()).Msg("render README, showing plain text") | |
| 195 | 199 | } | |
| 196 | 200 | rd.HTML = template.HTML("<pre>" + html.EscapeString(string(body)) + "</pre>") // #nosec G203 -- escaped | |
| 197 | 201 | return &renderedReadme{readme: rd}, nil | |
| 198 | 202 | } | |
| 199 | 203 | ||
| 200 | - | // readBlob reads a file through the GetBlob stream, at most limit bytes. It | |
| 201 | - | // reports a binary file without its content. | |
| 202 | - | func (s *Server) readBlob(ctx context.Context, repo, rev, path string, limit int64) (body []byte, binary bool, err error) { | |
| 203 | - | stream, err := s.cfg.Git.GetBlob(ctx, connect.NewRequest(&pb.GetBlobRequest{Repo: repo, Revision: rev, Path: path})) | |
| 204 | + | // readBlob reads a file, at most limit bytes. It reports a binary file | |
| 205 | + | // without its content. | |
| 206 | + | func (s *Server) readBlob(ctx context.Context, repo domain.RepoName, rev, path string, limit int64) (body []byte, binary bool, err error) { | |
| 207 | + | info, rc, err := s.openBlobAt(ctx, repo, rev, path) | |
| 204 | 208 | if err != nil { | |
| 205 | 209 | return nil, false, err | |
| 206 | 210 | } | |
| 207 | - | defer func() { _ = stream.Close() }() | |
| 208 | - | for stream.Receive() { | |
| 209 | - | m := stream.Msg() | |
| 210 | - | if m.GetInfo().GetBinary() { | |
| 211 | - | return nil, true, nil | |
| 212 | - | } | |
| 213 | - | body = append(body, m.GetChunk()...) | |
| 214 | - | if int64(len(body)) > limit { | |
| 215 | - | return body[:limit], false, nil | |
| 216 | - | } | |
| 211 | + | defer func() { _ = rc.Close() }() | |
| 212 | + | if info.Binary { | |
| 213 | + | return nil, true, nil | |
| 217 | 214 | } | |
| 218 | - | if err := stream.Err(); err != nil { | |
| 215 | + | body, err = io.ReadAll(io.LimitReader(rc, limit+1)) | |
| 216 | + | if err != nil { | |
| 219 | 217 | return nil, false, err | |
| 220 | 218 | } | |
| 219 | + | if int64(len(body)) > limit { | |
| 220 | + | return body[:limit], false, nil | |
| 221 | + | } | |
| 221 | 222 | return body, false, nil | |
| 222 | 223 | } | |
| 223 | 224 | ||
M internal/adapters/web/text_test.go +1 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // Invisible characters in names and subjects show as markers. | |
| 12 | 12 | func TestVisibleText(t *testing.T) { | |
| 13 | + | t.Parallel() | |
| 13 | 14 | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | |
| 14 | 15 | assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”")) | |
| 15 | 16 | assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b")) | |
M internal/adapters/web/tree.go +20 -21
| @@ -6,10 +6,9 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | ||
| 9 | - | "connectrpc.com/connect" | |
| 10 | 9 | "github.com/a-h/templ" | |
| 11 | 10 | ||
| 12 | - | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | // TreeRow is one entry of a directory. | |
| @@ -42,6 +41,8 @@ type TreePage struct { | |||
| 42 | 41 | ||
| 43 | 42 | // tree lists a directory of a ref, with the last commit of each entry, and | |
| 44 | 43 | // renders the README of the directory below. | |
| 44 | + | // | |
| 45 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here | |
| 45 | 46 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 46 | 47 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 47 | 48 | if err != nil { | |
| @@ -51,52 +52,50 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 51 | 52 | if !ok { | |
| 52 | 53 | return nil, 0, errNotFound | |
| 53 | 54 | } | |
| 54 | - | res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{ | |
| 55 | - | Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true, | |
| 56 | - | })) | |
| 55 | + | res, err := s.getTree(ctx, d.repo.Name, t.rev, t.path, true) | |
| 57 | 56 | if err != nil { | |
| 58 | 57 | return nil, 0, err | |
| 59 | 58 | } | |
| 60 | - | entries := res.Msg.GetEntries() | |
| 59 | + | entries := res.Entries | |
| 61 | 60 | now := s.cfg.Now() | |
| 62 | 61 | ||
| 63 | 62 | rowsOnly := r.URL.Query().Has("rows") | |
| 64 | 63 | p := TreePage{} | |
| 65 | 64 | for i, e := range entries { | |
| 66 | - | row := TreeRow{Glyph: "·", Name: visibleText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0 && !rowsOnly} | |
| 67 | - | switch e.GetKind() { | |
| 68 | - | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | |
| 65 | + | row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly} | |
| 66 | + | switch e.Kind { | |
| 67 | + | case domain.EntryDirectory: | |
| 69 | 68 | row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-" | |
| 70 | - | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | |
| 69 | + | row.Href = repoHref(d.name(), "tree", t.name, e.Path) | |
| 71 | 70 | row.Expand = row.Href + "?rows" | |
| 72 | - | case pb.EntryKind_ENTRY_KIND_SYMLINK: | |
| 71 | + | case domain.EntrySymlink: | |
| 73 | 72 | row.Glyph = "@" | |
| 74 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |
| 75 | - | case pb.EntryKind_ENTRY_KIND_SUBMODULE: | |
| 73 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) | |
| 74 | + | case domain.EntrySubmodule: | |
| 76 | 75 | row.Glyph, row.Size = "+", "-" | |
| 77 | 76 | default: | |
| 78 | - | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |
| 77 | + | row.Href = repoHref(d.name(), "blob", t.name, e.Path) | |
| 79 | 78 | } | |
| 80 | - | if c := e.GetLastCommit(); c != nil { | |
| 81 | - | last := commitRow(d.name(), c, now) | |
| 79 | + | if c := e.LastCommit; c != nil { | |
| 80 | + | last := commitRow(d.name(), *c, now) | |
| 82 | 81 | row.Subject, row.Author, row.CHref, row.When, row.WhenAt = last.Subject, last.Author, last.CHref, last.When, last.WhenAt | |
| 83 | 82 | } | |
| 84 | 83 | p.Rows = append(p.Rows, row) | |
| 85 | 84 | } | |
| 86 | 85 | if rowsOnly { | |
| 87 | 86 | // The rows alone, for a directory opened in place (files.js). | |
| 88 | - | if res.Msg.GetTruncated() { | |
| 87 | + | if res.Truncated { | |
| 89 | 88 | p.More = repoHref(d.name(), "tree", t.name, t.path) | |
| 90 | 89 | } | |
| 91 | 90 | return TreeRows(p), http.StatusOK, nil | |
| 92 | 91 | } | |
| 93 | - | if res.Msg.GetTruncated() { | |
| 92 | + | if res.Truncated { | |
| 94 | 93 | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | |
| 95 | 94 | } | |
| 96 | - | if res.Msg.GetLastCommitsTruncated() { | |
| 95 | + | if res.LastCommitsOmitted { | |
| 97 | 96 | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | |
| 98 | 97 | } | |
| 99 | - | rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries) | |
| 98 | + | rd, err := s.readmeIn(ctx, d.repo.Name, res.CommitID.String(), t.name, t.path, entries) | |
| 100 | 99 | if err != nil { | |
| 101 | 100 | return nil, 0, err | |
| 102 | 101 | } | |
| @@ -107,7 +106,7 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in | |||
| 107 | 106 | l := s.repoLayout(ctx, d, "tree") | |
| 108 | 107 | // The finder searches the commit this page lists, whichever way the URL | |
| 109 | 108 | // spelled it (an abbreviated id included): the listing knows it. | |
| 110 | - | t.commit = res.Msg.GetCommitId() | |
| 109 | + | t.commit = res.CommitID.String() | |
| 111 | 110 | pinRef(&l, t) | |
| 112 | 111 | l.Crumbs = pathCrumbs(d.name(), "tree", t) | |
| 113 | 112 | if t.path == "" { | |
M internal/adapters/web/view.go +1 -1
| @@ -5,7 +5,7 @@ package web | |||
| 5 | 5 | import "html/template" | |
| 6 | 6 | ||
| 7 | 7 | // View models handed to the templ components. Components only loop and | |
| 8 | - | // branch on these; the handlers build them from the API messages. | |
| 8 | + | // branch on these; the handlers build them from the use case results. | |
| 9 | 9 | ||
| 10 | 10 | // Crumb is one segment of the path in the header. | |
| 11 | 11 | type Crumb struct { | |
M internal/adapters/web/web.go +70 -35
| @@ -1,15 +1,16 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | 3 | // Package web is the HTML front of ermite: the pages of the read-only | |
| 4 | - | // showcase, rendered with templ from the data of the ermite API. | |
| 4 | + | // showcase, rendered with templ from the data of the use cases. | |
| 5 | 5 | // | |
| 6 | - | // The package is a client of the API like any other. It talks to the | |
| 7 | - | // generated ermite-api Connect clients, which cmd binds in process to the | |
| 8 | - | // handlers of the rpc adapter (ADR 0020), and it knows nothing of the use | |
| 9 | - | // cases or the domain. Who the visitor is comes with the request context, set | |
| 10 | - | // by the middleware cmd puts in front of the handler: anonymous in P5. The | |
| 11 | - | // API then shows public repositories only; a private repository is | |
| 12 | - | // indistinguishable from a missing one, here as everywhere. | |
| 6 | + | // Like every other inbound adapter, the package reads and writes through the | |
| 7 | + | // use case interfaces of package ports (ADR 0024), never through the API. The | |
| 8 | + | // caller of every use case call is passed explicitly: the principal of the | |
| 9 | + | // request, which is anonymous unless the request carries a valid owner session | |
| 10 | + | // (ServeHTTP sets it in the context, principal reads it). The use cases then | |
| 11 | + | // show public repositories only to an anonymous caller; a private repository | |
| 12 | + | // is indistinguishable from a missing one (domain.ErrNotFound), here as | |
| 13 | + | // everywhere. | |
| 13 | 14 | // | |
| 14 | 15 | // Adding a page: a handler on Server, a route in routes, a templ page in | |
| 15 | 16 | // pages.templ and its view model in view.go. | |
| @@ -28,11 +29,11 @@ import ( | |||
| 28 | 29 | "strings" | |
| 29 | 30 | "time" | |
| 30 | 31 | ||
| 31 | - | "connectrpc.com/connect" | |
| 32 | 32 | "github.com/a-h/templ" | |
| 33 | 33 | "github.com/bnema/zerowrap" | |
| 34 | 34 | ||
| 35 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 35 | + | "bnema.dev/ermite/internal/domain" | |
| 36 | + | "bnema.dev/ermite/internal/ports" | |
| 36 | 37 | ) | |
| 37 | 38 | ||
| 38 | 39 | // Patterns are the ServeMux patterns the handler serves: everything. The | |
| @@ -41,20 +42,22 @@ import ( | |||
| 41 | 42 | // over this one. | |
| 42 | 43 | var Patterns = []string{"/"} | |
| 43 | 44 | ||
| 44 | - | // requestTimeout bounds the API calls that build one page. | |
| 45 | + | // requestTimeout bounds the use case calls that build one page. | |
| 45 | 46 | const requestTimeout = 30 * time.Second | |
| 46 | 47 | ||
| 47 | 48 | // Config configures a Server. | |
| 48 | 49 | type Config struct { | |
| 49 | - | // Repos and Git are the API clients the pages are built from. | |
| 50 | - | Repos ermiteconnect.RepoServiceClient | |
| 51 | - | Git ermiteconnect.GitServiceClient | |
| 52 | - | // MergeRequests is the client of the merge request pages (ADR 0017). | |
| 53 | - | // Without it there are no such pages, nor section for them. | |
| 54 | - | MergeRequests ermiteconnect.MergeRequestServiceClient | |
| 55 | - | // Events is the client of the event stream behind /-/events. Without | |
| 56 | - | // it the pages are not live and the route answers 404. | |
| 57 | - | Events ermiteconnect.EventServiceClient | |
| 50 | + | // Repos and Browse are the use cases the pages are built from. | |
| 51 | + | Repos ports.RepoService | |
| 52 | + | Browse ports.BrowseService | |
| 53 | + | // MergeRequests serves the merge request pages (ADR 0017). Without it | |
| 54 | + | // there are no such pages, nor section for them. Leave it unset rather | |
| 55 | + | // than assigning a typed nil pointer. | |
| 56 | + | MergeRequests ports.MergeRequestService | |
| 57 | + | // Events is the source of the event stream behind /-/events. Without | |
| 58 | + | // it the pages are not live and the route answers 404. Leave it unset | |
| 59 | + | // rather than assigning a typed nil pointer. | |
| 60 | + | Events ports.EventService | |
| 58 | 61 | // Heartbeat is how often an idle event stream gets a comment line; | |
| 59 | 62 | // DefaultHeartbeat when zero. | |
| 60 | 63 | Heartbeat time.Duration | |
| @@ -69,10 +72,10 @@ type Config struct { | |||
| 69 | 72 | Now func() time.Time | |
| 70 | 73 | // Sessions signs the owner in (ADR 0018). Without it there are no login | |
| 71 | 74 | // routes and every visitor is anonymous. | |
| 72 | - | Sessions SessionAuth | |
| 75 | + | Sessions ports.WebSessionService | |
| 73 | 76 | // Limiter counts the failed logins; required with Sessions. Share it with | |
| 74 | 77 | // the API and Git, so that a client has one budget of failures. | |
| 75 | - | Limiter FailureLimiter | |
| 78 | + | Limiter ports.FailureLimiter | |
| 76 | 79 | // TrustedProxies are the networks allowed to set X-Forwarded-For, to find | |
| 77 | 80 | // the address of the client that fails a login. | |
| 78 | 81 | TrustedProxies []netip.Prefix | |
| @@ -88,12 +91,15 @@ type Server struct { | |||
| 88 | 91 | // pathCache holds the path lists the file finder fetches. | |
| 89 | 92 | pathCache *pathCache | |
| 90 | 93 | mux *http.ServeMux | |
| 94 | + | // refsFan shares the fragments of the refs page between the watchers of | |
| 95 | + | // a push; entries are few and short-lived. | |
| 96 | + | refsFan fanout | |
| 91 | 97 | } | |
| 92 | 98 | ||
| 93 | 99 | // New builds the web handler. | |
| 94 | 100 | func New(cfg Config) (*Server, error) { | |
| 95 | - | if cfg.Repos == nil || cfg.Git == nil { | |
| 96 | - | return nil, errors.New("web: the repository and Git API clients are required") | |
| 101 | + | if cfg.Repos == nil || cfg.Browse == nil { | |
| 102 | + | return nil, errors.New("web: the repository and browse use cases are required") | |
| 97 | 103 | } | |
| 98 | 104 | u, err := url.Parse(cfg.PublicURL) | |
| 99 | 105 | if err != nil || u.Host == "" { | |
| @@ -161,6 +167,9 @@ func (s *Server) routes() *http.ServeMux { | |||
| 161 | 167 | ||
| 162 | 168 | // ServeHTTP implements http.Handler. | |
| 163 | 169 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 170 | + | // Whatever the context carried, the caller is anonymous until a valid | |
| 171 | + | // session says otherwise (withSession). | |
| 172 | + | r = r.WithContext(domain.ContextWithPrincipal(r.Context(), domain.Anonymous())) | |
| 164 | 173 | r = s.withSession(w, r) | |
| 165 | 174 | if !s.gate(w, r) { | |
| 166 | 175 | return | |
| @@ -172,7 +181,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 172 | 181 | // error, which becomes the 404 or 500 page. | |
| 173 | 182 | type handler func(ctx context.Context, r *http.Request) (templ.Component, int, error) | |
| 174 | 183 | ||
| 175 | - | // page adapts a handler: it bounds the API calls, renders the component into | |
| 184 | + | // page adapts a handler: it bounds the use case calls, renders the component into | |
| 176 | 185 | // a buffer (so that a failure never leaves half a page), and maps errors to | |
| 177 | 186 | // the state pages. | |
| 178 | 187 | func (s *Server) page(h handler) http.HandlerFunc { | |
| @@ -209,21 +218,47 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||
| 209 | 218 | if h.Get("Cache-Control") == "" { | |
| 210 | 219 | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 211 | 220 | } | |
| 221 | + | body := buf.Bytes() | |
| 222 | + | // Whether the body is sent gzipped follows from its size and the request | |
| 223 | + | // alone, so that the tag is known before the compression is spent. | |
| 224 | + | gz := len(body) >= minCompress && acceptsGzip(r.Header) && | |
| 225 | + | !noTransform(h) && h.Get("Content-Encoding") == "" | |
| 226 | + | addVary(h, "Accept-Encoding") | |
| 227 | + | readOnly := r.Method == http.MethodGet || r.Method == http.MethodHead | |
| 228 | + | // A page that may be stored gets a validator: the browser then | |
| 229 | + | // revalidates with If-None-Match and a page that did not change costs a | |
| 230 | + | // 304. A no-store page (the owner's) is never revalidated. | |
| 231 | + | if readOnly && status == http.StatusOK && h.Get("ETag") == "" && !hasCacheDirective(h, noStore) { | |
| 232 | + | tag := pageETag(body, SignedIn(ctx), gz) | |
| 233 | + | h.Set("ETag", tag) | |
| 234 | + | if etagMatches(strings.Join(r.Header.Values("If-None-Match"), ","), tag) { | |
| 235 | + | h.Del("Content-Type") | |
| 236 | + | w.WriteHeader(http.StatusNotModified) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | if gz { | |
| 241 | + | if z, err := gzipPage(body); err == nil && len(z) < len(body) { | |
| 242 | + | body = z | |
| 243 | + | h.Set("Content-Encoding", "gzip") | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | h.Set("Content-Length", itoa(len(body))) | |
| 212 | 247 | w.WriteHeader(status) | |
| 213 | 248 | if r.Method != http.MethodHead { | |
| 214 | - | _, _ = w.Write(buf.Bytes()) | |
| 249 | + | _, _ = w.Write(body) | |
| 215 | 250 | } | |
| 216 | 251 | } | |
| 217 | 252 | ||
| 218 | - | // fail answers an error of the API: not found (and a name or revision the | |
| 219 | - | // API refuses, which no page can show either) gives the 404 page, a visitor | |
| 220 | - | // that left gives nothing, anything else gives the 500 page with an id to | |
| 221 | - | // find the cause in the log. | |
| 253 | + | // fail answers an error of a use case: not found (and a name or revision the | |
| 254 | + | // use case refuses, which no page can show either) gives the 404 page, a | |
| 255 | + | // visitor that left gives nothing, anything else gives the 500 page with an id | |
| 256 | + | // to find the cause in the log. | |
| 222 | 257 | func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { | |
| 223 | - | switch code := connect.CodeOf(err); { | |
| 224 | - | case errors.Is(err, errNotFound), code == connect.CodeNotFound, code == connect.CodeInvalidArgument: | |
| 258 | + | switch { | |
| 259 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err): | |
| 225 | 260 | s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage()) | |
| 226 | - | case errors.Is(err, context.Canceled), code == connect.CodeCanceled: | |
| 261 | + | case errors.Is(err, context.Canceled): | |
| 227 | 262 | // The client is gone; there is nobody to answer. | |
| 228 | 263 | default: | |
| 229 | 264 | id := requestID() | |
| @@ -234,8 +269,8 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques | |||
| 234 | 269 | } | |
| 235 | 270 | ||
| 236 | 271 | // errNotFound is returned by handlers for a resource that does not exist | |
| 237 | - | // although the API did not say so (a ref or a path that is not there). | |
| 238 | - | var errNotFound = errors.New("not found") | |
| 272 | + | // although the use case did not say so (a ref or a path that is not there). | |
| 273 | + | var errNotFound = domain.ErrNotFound | |
| 239 | 274 | ||
| 240 | 275 | // requestID is a short random id, shown on the 500 page and logged. | |
| 241 | 276 | func requestID() string { | |
R cmd/ermite/api_helpers_test.go → internal/app/api_helpers_test.go +3 -3 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -19,8 +19,8 @@ import ( | |||
| 19 | 19 | ||
| 20 | 20 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | 21 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 22 | - | "bnema.dev/ermite/internal/app" | |
| 23 | 22 | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | 26 | // clients are the generated Connect clients of one caller. | |
| @@ -105,7 +105,7 @@ func codeName(err error) string { | |||
| 105 | 105 | // commit per file to its main branch with git. | |
| 106 | 106 | func (h *harness) seed(name string, vis domain.Visibility, files ...string) { | |
| 107 | 107 | h.t.Helper() | |
| 108 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: vis}) | |
| 108 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: vis}) | |
| 109 | 109 | require.NoError(h.t, err) | |
| 110 | 110 | if len(files) == 0 { | |
| 111 | 111 | return | |
R cmd/ermite/api_test.go → internal/app/api_test.go +5 -74 renamed, 91% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -24,9 +24,9 @@ import ( | |||
| 24 | 24 | ||
| 25 | 25 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 26 | 26 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 27 | - | "bnema.dev/ermite/internal/app" | |
| 28 | 27 | "bnema.dev/ermite/internal/clientlimit" | |
| 29 | 28 | "bnema.dev/ermite/internal/domain" | |
| 29 | + | "bnema.dev/ermite/internal/ports" | |
| 30 | 30 | ) | |
| 31 | 31 | ||
| 32 | 32 | // principals of the access tables. | |
| @@ -690,8 +690,8 @@ func TestGetBlobStreamsInChunks(t *testing.T) { | |||
| 690 | 690 | } | |
| 691 | 691 | } | |
| 692 | 692 | ||
| 693 | - | func createParams(h *harness, name string, vis domain.Visibility) app.CreateParams { | |
| 694 | - | return app.CreateParams{Name: h.name(name), Visibility: vis} | |
| 693 | + | func createParams(h *harness, name string, vis domain.Visibility) ports.CreateRepoParams { | |
| 694 | + | return ports.CreateRepoParams{Name: h.name(name), Visibility: vis} | |
| 695 | 695 | } | |
| 696 | 696 | ||
| 697 | 697 | // TestUpdateRepoDefaultBranch: HEAD moves with git symbolic-ref once the | |
| @@ -803,77 +803,8 @@ func TestDeleteRepoMovesToTrash(t *testing.T) { | |||
| 803 | 803 | assert.Equal(t, "not_found", codeName(err), "no commit of the deleted repository survives in a reader process") | |
| 804 | 804 | } | |
| 805 | 805 | ||
| 806 | - | // TestInProcessClient: the handlers answer calls made in the process with a | |
| 807 | - | // principal in the context, with no HTTP authentication, and refuse to be | |
| 808 | - | // fooled by an Authorization header. | |
| 809 | - | func TestInProcessClient(t *testing.T) { | |
| 810 | - | h := newHarness(t, harnessOpts{}) | |
| 811 | - | h.seed("pub", domain.VisibilityPublic, "README.md") | |
| 812 | - | h.seed("priv", domain.VisibilityPrivate, "README.md") | |
| 813 | - | c := newClientsOn(h.forge.API.InProcessClient(), rpc.InProcessURL) | |
| 814 | - | list := func(ctx context.Context) []string { | |
| 815 | - | resp, err := c.repo.ListRepos(ctx, connect.NewRequest(&pb.ListReposRequest{})) | |
| 816 | - | require.NoError(t, err) | |
| 817 | - | var out []string | |
| 818 | - | for _, r := range resp.Msg.GetRepos() { | |
| 819 | - | out = append(out, r.GetName()) | |
| 820 | - | } | |
| 821 | - | return out | |
| 822 | - | } | |
| 823 | - | ||
| 824 | - | assert.Equal(t, []string{"pub"}, list(h.ctx), "no principal in the context is anonymous") | |
| 825 | - | assert.Equal(t, []string{"pub"}, list(domain.ContextWithPrincipal(h.ctx, domain.Anonymous()))) | |
| 826 | - | owner := domain.ContextWithPrincipal(h.ctx, domain.Root()) | |
| 827 | - | assert.Equal(t, []string{"priv", "pub"}, list(owner)) | |
| 828 | - | ||
| 829 | - | // Mutations and streams work the same way. | |
| 830 | - | _, err := c.repo.CreateRepo(owner, connect.NewRequest(&pb.CreateRepoRequest{Name: "web-made"})) | |
| 831 | - | require.NoError(t, err) | |
| 832 | - | _, err = c.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "denied"})) | |
| 833 | - | assert.Equal(t, "unauthenticated", codeName(err)) | |
| 834 | - | msgs, err := readBlob(owner, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |
| 835 | - | require.NoError(t, err) | |
| 836 | - | assert.Equal(t, "content of README.md\n", string(msgs[0].GetChunk())) | |
| 837 | - | _, err = readBlob(h.ctx, c, &pb.GetBlobRequest{Repo: "priv", Path: "README.md"}) | |
| 838 | - | assert.Equal(t, "not_found", codeName(err)) | |
| 839 | - | ||
| 840 | - | wctx, cancel := context.WithCancel(owner) | |
| 841 | - | defer cancel() | |
| 842 | - | st, err := c.event.Watch(wctx, connect.NewRequest(&pb.WatchRequest{Topics: []string{"repo:web-made"}})) | |
| 843 | - | require.NoError(t, err) | |
| 844 | - | got := make(chan *pb.Event, 1) | |
| 845 | - | go func() { | |
| 846 | - | if st.Receive() { | |
| 847 | - | got <- st.Msg().GetEvent() | |
| 848 | - | } | |
| 849 | - | }() | |
| 850 | - | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 851 | - | _, err = c.repo.UpdateRepo(owner, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 852 | - | Repo: "web-made", Description: "d", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 853 | - | })) | |
| 854 | - | require.NoError(t, err) | |
| 855 | - | select { | |
| 856 | - | case ev := <-got: | |
| 857 | - | assert.Equal(t, "d", ev.GetRepoUpdated().GetRepo().GetDescription()) | |
| 858 | - | case <-time.After(10 * time.Second): | |
| 859 | - | t.Fatal("no event through the in-process client") | |
| 860 | - | } | |
| 861 | - | ||
| 862 | - | // An Authorization header does not give a principal in process. | |
| 863 | - | hc := h.forge.API.InProcessClient() | |
| 864 | - | req, _ := http.NewRequestWithContext(h.ctx, http.MethodPost, rpc.InProcessURL+"/ermite.RepoService/ListRepos", strings.NewReader("{}")) | |
| 865 | - | req.Header.Set("Content-Type", "application/json") | |
| 866 | - | req.Header.Set("Authorization", "Bearer "+adminToken) | |
| 867 | - | resp, err := hc.Do(req) | |
| 868 | - | require.NoError(t, err) | |
| 869 | - | body, _ := io.ReadAll(resp.Body) | |
| 870 | - | _ = resp.Body.Close() | |
| 871 | - | assert.NotContains(t, string(body), `"priv"`) | |
| 872 | - | } | |
| 873 | - | ||
| 874 | 806 | // A principal in the base context of the HTTP server does not authenticate | |
| 875 | - | // requests: only the in-process transport can mark a context as trusted, and | |
| 876 | - | // requests from the network are judged by their headers. | |
| 807 | + | // requests: requests from the network are judged by their headers. | |
| 877 | 808 | func TestNetworkRequestsIgnoreAPrincipalInTheContext(t *testing.T) { | |
| 878 | 809 | h := newHarness(t, harnessOpts{}) | |
| 879 | 810 | h.seed("pub", domain.VisibilityPublic, "README.md") | |
A internal/app/app.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package app is the composition root and the command line of ermite: it | |
| 4 | + | // parses the CLI, reads the environment, builds the adapters and wires them to | |
| 5 | + | // the use cases. cmd/ermite is only its entry point. | |
| 6 | + | package app | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "runtime/debug" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Run executes the ermite command line with args, the command-line arguments | |
| 13 | + | // without the program name. It returns the error of the command, which the | |
| 14 | + | // caller prints once. | |
| 15 | + | func Run(args []string) error { | |
| 16 | + | root := newRootCmd() | |
| 17 | + | if args == nil { | |
| 18 | + | args = []string{} | |
| 19 | + | } | |
| 20 | + | root.SetArgs(args) | |
| 21 | + | return root.Execute() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ExitCode is the process exit status for the error Run returned. | |
| 25 | + | func ExitCode(err error) int { | |
| 26 | + | if err == nil { | |
| 27 | + | return 0 | |
| 28 | + | } | |
| 29 | + | return 1 | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // stamped is the release version set at link time by the image build | |
| 33 | + | // (-ldflags "-X bnema.dev/ermite/internal/app.stamped=v0.1.0"). Empty for a | |
| 34 | + | // plain go build, which then reports the module version recorded by the Go | |
| 35 | + | // toolchain. | |
| 36 | + | var stamped string | |
| 37 | + | ||
| 38 | + | // buildVersion is the version stamped into the binary: the link-time version | |
| 39 | + | // when set, else the module version of the build. | |
| 40 | + | func buildVersion() string { | |
| 41 | + | if stamped != "" { | |
| 42 | + | return stamped | |
| 43 | + | } | |
| 44 | + | info, ok := debug.ReadBuildInfo() | |
| 45 | + | if !ok || info.Main.Version == "" { | |
| 46 | + | return "(devel)" | |
| 47 | + | } | |
| 48 | + | return info.Main.Version | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func version() string { return "ermite " + buildVersion() } | |
R cmd/ermite/cli.go → internal/app/cli.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_binary_test.go → internal/app/cli_binary_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
R cmd/ermite/cli_e2e_test.go → internal/app/cli_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_fake_test.go → internal/app/cli_fake_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
R cmd/ermite/cli_mr.go → internal/app/cli_mr.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_mr_e2e_test.go → internal/app/cli_mr_e2e_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/cli_mr_test.go → internal/app/cli_mr_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "strings" | |
R cmd/ermite/cli_repo.go → internal/app/cli_repo.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_repo_test.go → internal/app/cli_repo_test.go +50 -36 renamed, 88% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| @@ -34,41 +34,55 @@ func envFor(url string) map[string]string { | |||
| 34 | 34 | return map[string]string{"ERMITE_URL": url, "ERMITE_TOKEN": "ermite_tok"} | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | func TestRepoListTable(t *testing.T) { | |
| 38 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 39 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | |
| 40 | - | }} | |
| 41 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list").ok(t) | |
| 42 | - | assert.Equal(t, ""+ | |
| 43 | - | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n"+ | |
| 44 | - | "alpha public main 2026-03-04 05:06 The first one\n"+ | |
| 45 | - | "beta private trunk never \n", res.stdout) | |
| 46 | - | assert.Empty(t, res.stderr) | |
| 47 | - | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |
| 48 | - | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func TestRepoListJSON(t *testing.T) { | |
| 52 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 53 | - | return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha, repoBeta}}, nil | |
| 54 | - | }} | |
| 55 | - | res := runCLI(t, envFor(f.serve(t)), "", "repo", "list", "--json").ok(t) | |
| 56 | - | assert.JSONEq(t, `[ | |
| 57 | - | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | |
| 58 | - | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 59 | - | ]`, res.stdout) | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | func TestRepoListEmpty(t *testing.T) { | |
| 63 | - | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 64 | - | return &ermite.ListReposResponse{}, nil | |
| 65 | - | }} | |
| 66 | - | url := f.serve(t) | |
| 67 | - | res := runCLI(t, envFor(url), "", "repo", "list").ok(t) | |
| 68 | - | assert.Empty(t, res.stdout) | |
| 69 | - | assert.Equal(t, "ermite: no repositories\n", res.stderr) | |
| 70 | - | res = runCLI(t, envFor(url), "", "repo", "list", "--json").ok(t) | |
| 71 | - | assert.Equal(t, "[]\n", res.stdout, "an empty list is [] not null") | |
| 37 | + | func TestRepoList(t *testing.T) { | |
| 38 | + | both := []*ermite.Repo{repoAlpha, repoBeta} | |
| 39 | + | tests := []struct { | |
| 40 | + | name string | |
| 41 | + | repos []*ermite.Repo | |
| 42 | + | args []string | |
| 43 | + | wantStdout string | |
| 44 | + | wantJSON bool // compare stdout as JSON rather than byte for byte | |
| 45 | + | wantStderr string | |
| 46 | + | }{ | |
| 47 | + | { | |
| 48 | + | name: "table", repos: both, args: []string{"repo", "list"}, | |
| 49 | + | wantStdout: "" + | |
| 50 | + | "NAME VISIBILITY BRANCH LAST PUSH DESCRIPTION\n" + | |
| 51 | + | "alpha public main 2026-03-04 05:06 The first one\n" + | |
| 52 | + | "beta private trunk never \n", | |
| 53 | + | }, | |
| 54 | + | { | |
| 55 | + | name: "json", repos: both, args: []string{"repo", "list", "--json"}, wantJSON: true, | |
| 56 | + | wantStdout: `[ | |
| 57 | + | {"name":"alpha","description":"The first one","visibility":"public","default_branch":"main","created_at":"2026-01-02T03:04:05Z","pushed_at":"2026-03-04T05:06:07Z","merge_modes":["fast-forward","squash"],"failing_checks_block":true}, | |
| 58 | + | {"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false} | |
| 59 | + | ]`, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | name: "empty, table", args: []string{"repo", "list"}, | |
| 63 | + | wantStderr: "ermite: no repositories\n", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "empty, json is [] not null", args: []string{"repo", "list", "--json"}, | |
| 67 | + | wantStdout: "[]\n", | |
| 68 | + | }, | |
| 69 | + | } | |
| 70 | + | for _, tt := range tests { | |
| 71 | + | t.Run(tt.name, func(t *testing.T) { | |
| 72 | + | f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) { | |
| 73 | + | return &ermite.ListReposResponse{Repos: tt.repos}, nil | |
| 74 | + | }} | |
| 75 | + | res := runCLI(t, envFor(f.serve(t)), "", tt.args...).ok(t) | |
| 76 | + | if tt.wantJSON { | |
| 77 | + | assert.JSONEq(t, tt.wantStdout, res.stdout) | |
| 78 | + | } else { | |
| 79 | + | assert.Equal(t, tt.wantStdout, res.stdout) | |
| 80 | + | } | |
| 81 | + | assert.Equal(t, tt.wantStderr, res.stderr) | |
| 82 | + | assert.Equal(t, "Bearer ermite_tok", f.header[0].Get("Authorization")) | |
| 83 | + | assert.Equal(t, "ermite/v0.0.1 (cli)", f.header[0].Get("User-Agent")) | |
| 84 | + | }) | |
| 85 | + | } | |
| 72 | 86 | } | |
| 73 | 87 | ||
| 74 | 88 | func TestRepoListFollowsPages(t *testing.T) { | |
R cmd/ermite/cli_signing.go → internal/app/cli_signing.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_token.go → internal/app/cli_token.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/cli_token_test.go → internal/app/cli_token_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "testing" | |
R cmd/ermite/cli_version.go → internal/app/cli_version.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_version_test.go → internal/app/cli_version_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
R cmd/ermite/cli_watch.go → internal/app/cli_watch.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/cli_watch_test.go → internal/app/cli_watch_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
R cmd/ermite/cli_web.go → internal/app/cli_web.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
R cmd/ermite/clock.go → internal/app/clock.go +1 -1 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import "time" | |
| 6 | 6 | ||
R cmd/ermite/datadir.go → internal/app/datadir.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/e2e_helpers_test.go → internal/app/e2e_helpers_test.go +4 -4 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -18,9 +18,9 @@ import ( | |||
| 18 | 18 | "github.com/bnema/zerowrap" | |
| 19 | 19 | "github.com/stretchr/testify/require" | |
| 20 | 20 | ||
| 21 | - | "bnema.dev/ermite/internal/app" | |
| 22 | 21 | "bnema.dev/ermite/internal/config" | |
| 23 | 22 | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 24 | 24 | ) | |
| 25 | 25 | ||
| 26 | 26 | // harness is a forge wired like `ermite serve`, behind an httptest server, | |
| @@ -69,7 +69,7 @@ func newHarness(t *testing.T, o harnessOpts) *harness { | |||
| 69 | 69 | if o.trustedProxies != "" { | |
| 70 | 70 | env[config.EnvTrustedProxies] = o.trustedProxies | |
| 71 | 71 | } | |
| 72 | - | // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./cmd/ermite | |
| 72 | + | // The suite runs with either reader: ERMITE_GIT_READER=cli go test ./internal/app | |
| 73 | 73 | if r := os.Getenv(config.EnvGitReader); r != "" { | |
| 74 | 74 | env[config.EnvGitReader] = r | |
| 75 | 75 | } | |
| @@ -186,7 +186,7 @@ func (h *harness) status(method, path string, hdr http.Header) int { | |||
| 186 | 186 | // token creates a stored token through the use case, as root. | |
| 187 | 187 | func (h *harness) token(scopes []domain.Scope, repos ...string) string { | |
| 188 | 188 | h.t.Helper() | |
| 189 | - | p := app.CreateTokenParams{Label: "e2e", Scopes: scopes} | |
| 189 | + | p := ports.CreateTokenParams{Label: "e2e", Scopes: scopes} | |
| 190 | 190 | for _, r := range repos { | |
| 191 | 191 | p.Repos = append(p.Repos, h.name(r)) | |
| 192 | 192 | } | |
R cmd/ermite/e2e_test.go → internal/app/e2e_test.go +9 -9 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -20,8 +20,8 @@ import ( | |||
| 20 | 20 | "github.com/stretchr/testify/assert" | |
| 21 | 21 | "github.com/stretchr/testify/require" | |
| 22 | 22 | ||
| 23 | - | "bnema.dev/ermite/internal/app" | |
| 24 | 23 | "bnema.dev/ermite/internal/domain" | |
| 24 | + | "bnema.dev/ermite/internal/ports" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | 27 | func scopes(s ...domain.Scope) []domain.Scope { return s } | |
| @@ -29,7 +29,7 @@ func scopes(s ...domain.Scope) []domain.Scope { return s } | |||
| 29 | 29 | // publicRepo creates a public repository with one commit pushed by root. | |
| 30 | 30 | func (h *harness) publicRepo(name string, files ...string) { | |
| 31 | 31 | h.t.Helper() | |
| 32 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 32 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 33 | 33 | require.NoError(h.t, err) | |
| 34 | 34 | wt := h.workTree("main", files...) | |
| 35 | 35 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main") | |
| @@ -189,7 +189,7 @@ func TestAC2PushAuthorization(t *testing.T) { | |||
| 189 | 189 | assert.Contains(t, out, "Authentication failed") | |
| 190 | 190 | ||
| 191 | 191 | // Revoked token. | |
| 192 | - | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), app.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 192 | + | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 193 | 193 | require.NoError(t, err) | |
| 194 | 194 | next("before-revoke") | |
| 195 | 195 | out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main") | |
| @@ -570,20 +570,20 @@ func TestAC8TokenCreationThroughTheForge(t *testing.T) { | |||
| 570 | 570 | h := newHarness(t, harnessOpts{}) | |
| 571 | 571 | ctx := context.Background() | |
| 572 | 572 | ||
| 573 | - | admin, err := h.forge.Tokens.Create(ctx, domain.Root(), app.CreateTokenParams{ | |
| 573 | + | admin, err := h.forge.Tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{ | |
| 574 | 574 | Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}, | |
| 575 | 575 | }) | |
| 576 | 576 | require.NoError(t, err) | |
| 577 | 577 | p, err := h.forge.Auth.Authenticate(ctx, admin.Secret) | |
| 578 | 578 | require.NoError(t, err) | |
| 579 | 579 | ||
| 580 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}}) | |
| 580 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}}) | |
| 581 | 581 | assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation") | |
| 582 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 582 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)}) | |
| 583 | 583 | assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation") | |
| 584 | - | _, err = h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}}) | |
| 584 | + | _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}}) | |
| 585 | 585 | assert.ErrorIs(t, err, domain.ErrForbidden, "another repository") | |
| 586 | - | child, err := h.forge.Tokens.Create(ctx, p, app.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}}) | |
| 586 | + | child, err := h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}}) | |
| 587 | 587 | require.NoError(t, err) | |
| 588 | 588 | ||
| 589 | 589 | // The child works on its repository, and revoking the index entry kills it at once. | |
M internal/app/events_test.go +338 -700
| @@ -1,767 +1,405 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | - | "sync" | |
| 9 | + | "net/http" | |
| 10 | + | "os/exec" | |
| 11 | + | "syscall" | |
| 10 | 12 | "testing" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| 15 | + | "connectrpc.com/connect" | |
| 13 | 16 | "github.com/stretchr/testify/assert" | |
| 14 | - | "github.com/stretchr/testify/mock" | |
| 15 | 17 | "github.com/stretchr/testify/require" | |
| 18 | + | "google.golang.org/protobuf/types/known/fieldmaskpb" | |
| 16 | 19 | ||
| 17 | - | "bnema.dev/ermite/internal/app" | |
| 20 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 18 | 21 | "bnema.dev/ermite/internal/domain" | |
| 19 | - | "bnema.dev/ermite/internal/ports/mocks" | |
| 22 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | + | "bnema.dev/ermite/internal/usecase" | |
| 20 | 24 | ) | |
| 21 | 25 | ||
| 22 | - | // fakeAuth is the Revalidator of the rig: tokens are valid until revoked. | |
| 23 | - | type fakeAuth struct { | |
| 24 | - | mu sync.Mutex | |
| 25 | - | revoked map[string]bool | |
| 26 | - | calls int | |
| 26 | + | // watchWait is how long a test waits for an event that must arrive. Only | |
| 27 | + | // TestAC5PushEventWithinASecond holds the server to a second. | |
| 28 | + | const watchWait = 10 * time.Second | |
| 29 | + | ||
| 30 | + | // AC5: a push shows up in Watch within a second, with the ref updates. | |
| 31 | + | func TestAC5PushEventWithinASecond(t *testing.T) { | |
| 32 | + | h := newHarness(t, harnessOpts{}) | |
| 33 | + | h.seed("pub", domain.VisibilityPublic) | |
| 34 | + | w := h.watch(h.api(""), "", "home") | |
| 35 | + | ||
| 36 | + | wt := h.workTree("main", "a.txt") | |
| 37 | + | start := time.Now() | |
| 38 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 39 | + | ev := w.next(t, time.Second) | |
| 40 | + | assert.Less(t, time.Since(start), 2*time.Second) | |
| 41 | + | ||
| 42 | + | pushed := ev.GetPushed() | |
| 43 | + | require.NotNil(t, pushed, "got %v", ev) | |
| 44 | + | assert.Equal(t, "pub", pushed.GetRepo()) | |
| 45 | + | require.Len(t, pushed.GetRefUpdates(), 1) | |
| 46 | + | up := pushed.GetRefUpdates()[0] | |
| 47 | + | assert.Equal(t, "refs/heads/main", up.GetRef()) | |
| 48 | + | assert.Empty(t, up.GetOldId(), "a created ref has no old id") | |
| 49 | + | assert.Equal(t, h.bareGit("pub", "rev-parse", "main"), up.GetNewId()) | |
| 50 | + | assert.NotEmpty(t, ev.GetId()) | |
| 51 | + | assert.NotNil(t, ev.GetTime()) | |
| 52 | + | ||
| 53 | + | // A second push to the same ref carries both ids; a deletion has no new id. | |
| 54 | + | old := up.GetNewId() | |
| 55 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 56 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 57 | + | up2 := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 58 | + | assert.Equal(t, old, up2.GetOldId()) | |
| 59 | + | assert.NotEqual(t, old, up2.GetNewId()) | |
| 60 | + | ||
| 61 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main:refs/heads/tmp") | |
| 62 | + | w.next(t, watchWait) | |
| 63 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), ":refs/heads/tmp") | |
| 64 | + | del := w.next(t, watchWait).GetPushed().GetRefUpdates()[0] | |
| 65 | + | assert.Equal(t, "refs/heads/tmp", del.GetRef()) | |
| 66 | + | assert.NotEmpty(t, del.GetOldId()) | |
| 67 | + | assert.Empty(t, del.GetNewId()) | |
| 27 | 68 | } | |
| 28 | 69 | ||
| 29 | - | func (a *fakeAuth) Revalidate(_ context.Context, p domain.Principal) error { | |
| 30 | - | a.mu.Lock() | |
| 31 | - | defer a.mu.Unlock() | |
| 32 | - | a.calls++ | |
| 33 | - | if a.revoked[p.TokenID()] { | |
| 34 | - | return domain.ErrUnauthenticated | |
| 35 | - | } | |
| 36 | - | return nil | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | func (a *fakeAuth) revoke(id string) { | |
| 40 | - | a.mu.Lock() | |
| 41 | - | defer a.mu.Unlock() | |
| 42 | - | if a.revoked == nil { | |
| 43 | - | a.revoked = map[string]bool{} | |
| 44 | - | } | |
| 45 | - | a.revoked[id] = true | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func (a *fakeAuth) count() int { | |
| 49 | - | a.mu.Lock() | |
| 50 | - | defer a.mu.Unlock() | |
| 51 | - | return a.calls | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // eventRig is an Events use case fed by a channel the test writes to. | |
| 55 | - | type eventRig struct { | |
| 56 | - | t *testing.T | |
| 57 | - | meta *mocks.RepoMetaStore | |
| 58 | - | git *mocks.GitRepos | |
| 59 | - | feed chan domain.Event | |
| 60 | - | auth *fakeAuth | |
| 61 | - | events *app.Events | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | func newEventRig(t *testing.T, size int) *eventRig { | |
| 65 | - | t.Helper() | |
| 66 | - | return newEventRigConfig(t, app.EventsConfig{Size: size}) | |
| 70 | + | // AC5: creating, updating and deleting repositories through the API publish | |
| 71 | + | // RepoCreated, RepoUpdated and RepoDeleted; so does a create-on-push. | |
| 72 | + | func TestAC5RepoLifecycleEvents(t *testing.T) { | |
| 73 | + | h := newHarness(t, harnessOpts{}) | |
| 74 | + | root := h.api(adminToken) | |
| 75 | + | w := h.watch(root, "") | |
| 76 | + | ||
| 77 | + | _, err := root.repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "life", Description: "d", Visibility: pb.Visibility_VISIBILITY_PUBLIC})) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | created := w.next(t, watchWait).GetRepoCreated().GetRepo() | |
| 80 | + | require.NotNil(t, created) | |
| 81 | + | assert.Equal(t, "life", created.GetName()) | |
| 82 | + | assert.Equal(t, pb.Visibility_VISIBILITY_PUBLIC, created.GetVisibility()) | |
| 83 | + | assert.Equal(t, "main", created.GetDefaultBranch()) | |
| 84 | + | ||
| 85 | + | _, err = root.repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 86 | + | Repo: "life", Description: "new", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 87 | + | })) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | updated := w.next(t, watchWait).GetRepoUpdated().GetRepo() | |
| 90 | + | require.NotNil(t, updated) | |
| 91 | + | assert.Equal(t, "new", updated.GetDescription()) | |
| 92 | + | ||
| 93 | + | _, err = root.repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "life"})) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | assert.Equal(t, "life", w.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 96 | + | ||
| 97 | + | // Create on first push publishes RepoCreated once, then the push. | |
| 98 | + | wt := h.workTree("main", "a.txt") | |
| 99 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/fresh.git"), "main") | |
| 100 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 101 | + | assert.Equal(t, "fresh", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 102 | + | // Nothing else came in between: the next event is the sentinel. | |
| 103 | + | h.sentinel(t, w, "s-life") | |
| 67 | 104 | } | |
| 68 | 105 | ||
| 69 | - | func newEventRigConfig(t *testing.T, cfg app.EventsConfig) *eventRig { | |
| 70 | - | t.Helper() | |
| 71 | - | r := &eventRig{ | |
| 72 | - | t: t, | |
| 73 | - | meta: mocks.NewRepoMetaStore(t), | |
| 74 | - | git: mocks.NewGitRepos(t), | |
| 75 | - | feed: make(chan domain.Event, 4096), | |
| 76 | - | auth: &fakeAuth{}, | |
| 77 | - | } | |
| 78 | - | cfg.Auth = r.auth | |
| 79 | - | bus := mocks.NewEventBus(t) | |
| 80 | - | bus.EXPECT().Subscribe(mock.Anything).RunAndReturn(func(ctx context.Context) <-chan domain.Event { | |
| 81 | - | go func() { <-ctx.Done(); close(r.feed) }() | |
| 82 | - | return r.feed | |
| 83 | - | }) | |
| 84 | - | clock := mocks.NewClock(t) | |
| 85 | - | clock.EXPECT().Now().Return(now).Maybe() | |
| 86 | - | r.git.EXPECT().HeadBranch(mock.Anything, mock.Anything).Return("main", nil).Maybe() | |
| 87 | - | repos := app.NewRepos(r.meta, r.git, bus, clock) | |
| 88 | - | r.events = app.NewEvents(context.Background(), repos, bus, clock, cfg) | |
| 89 | - | t.Cleanup(func() { r.events.Close(context.Background()) }) | |
| 90 | - | return r | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // repo declares a repository the rig knows. | |
| 94 | - | func (r *eventRig) repo(s string, vis domain.Visibility) domain.RepoName { | |
| 95 | - | n := name(r.t, s) | |
| 96 | - | r.meta.EXPECT().Get(mock.Anything, n).Return(domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil).Maybe() | |
| 97 | - | return n | |
| 98 | - | } | |
| 106 | + | // AC5: resume from an id replays what came after, then goes live. | |
| 107 | + | func TestAC5Resume(t *testing.T) { | |
| 108 | + | h := newHarness(t, harnessOpts{}) | |
| 109 | + | h.seed("pub", domain.VisibilityPublic) | |
| 110 | + | api := h.api("") | |
| 111 | + | w := h.watch(api, "") | |
| 99 | 112 | ||
| 100 | - | // pushEvent builds the PushEvent the Pushes use case would publish now: it | |
| 101 | - | // carries the repository as the store answers at this moment. | |
| 102 | - | func (r *eventRig) pushEvent(n domain.RepoName, oid, ref string) domain.PushEvent { | |
| 103 | - | repo, err := r.meta.Get(context.Background(), n) | |
| 104 | - | require.NoError(r.t, err) | |
| 105 | - | return domain.PushEvent{ | |
| 106 | - | Repo: n, Updates: []domain.RefUpdate{{Old: domain.ZeroOID, New: oid, Ref: ref}}, At: now, | |
| 107 | - | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | |
| 113 | + | wt := h.workTree("main", "a.txt") | |
| 114 | + | var ids []string | |
| 115 | + | for i := range 4 { | |
| 116 | + | if i > 0 { | |
| 117 | + | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 118 | + | } | |
| 119 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 120 | + | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 108 | 121 | } | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func (r *eventRig) push(n domain.RepoName, i int) { | |
| 112 | - | r.feed <- r.pushEvent(n, fmt.Sprintf("%040x", i+1), "refs/heads/main") | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // watcher is a running Watch. | |
| 116 | - | type watcher struct { | |
| 117 | - | got chan app.WatchEvent | |
| 118 | - | done chan error | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func (r *eventRig) watch(ctx context.Context, p domain.Principal, params app.WatchParams) *watcher { | |
| 122 | - | w := &watcher{got: make(chan app.WatchEvent, 4096), done: make(chan error, 1)} | |
| 123 | - | go func() { | |
| 124 | - | w.done <- r.events.Watch(ctx, p, params, func(e app.WatchEvent) error { | |
| 125 | - | w.got <- e | |
| 126 | - | return nil | |
| 127 | - | }) | |
| 128 | - | }() | |
| 129 | - | return w | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | func (w *watcher) next(t *testing.T) app.WatchEvent { | |
| 133 | - | t.Helper() | |
| 134 | - | select { | |
| 135 | - | case e := <-w.got: | |
| 136 | - | return e | |
| 137 | - | case err := <-w.done: | |
| 138 | - | t.Fatalf("watch ended: %v", err) | |
| 139 | - | case <-time.After(5 * time.Second): | |
| 140 | - | t.Fatal("no event") | |
| 122 | + | w.cancel() | |
| 123 | + | ||
| 124 | + | // The client lost its connection after ids[1]: it gets 2 and 3, then live. | |
| 125 | + | r := h.watch(api, ids[1]) | |
| 126 | + | for _, want := range ids[2:] { | |
| 127 | + | ev := r.next(t, watchWait) | |
| 128 | + | assert.Equal(t, want, ev.GetId()) | |
| 129 | + | assert.NotNil(t, ev.GetPushed()) | |
| 141 | 130 | } | |
| 142 | - | return app.WatchEvent{} | |
| 131 | + | // Nothing was replayed twice: the next event is the live one, below. | |
| 132 | + | h.writeFile(wt, "live.txt", []byte("x\n")) | |
| 133 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 134 | + | live := r.next(t, watchWait) | |
| 135 | + | assert.NotNil(t, live.GetPushed()) | |
| 136 | + | assert.NotContains(t, ids, live.GetId()) | |
| 137 | + | ||
| 138 | + | // An id nobody issued is a gap, followed by the oldest buffered event: the | |
| 139 | + | // creation of the repository, which came before the first push. | |
| 140 | + | g := h.watch(api, "not-an-id") | |
| 141 | + | assert.NotNil(t, g.next(t, watchWait).GetGap()) | |
| 142 | + | assert.Equal(t, "pub", g.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 143 | + | assert.Equal(t, ids[0], g.next(t, watchWait).GetId()) | |
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | - | // sentinelOID marks the push events that only prove the ones before them were | |
| 146 | - | // handled. | |
| 147 | - | var sentinelOID = fmt.Sprintf("%040x", 0xfeed) | |
| 146 | + | // AC5: a gap when the id fell out of a small ring. | |
| 147 | + | func TestAC5GapAfterOverflow(t *testing.T) { | |
| 148 | + | h := newHarness(t, harnessOpts{eventBuffer: 4}) | |
| 149 | + | h.seed("pub", domain.VisibilityPublic) | |
| 150 | + | api := h.api(adminToken) | |
| 151 | + | w := h.watch(api, "") | |
| 152 | + | wt := h.workTree("main", "a.txt") | |
| 148 | 153 | ||
| 149 | - | // quiet proves that the watcher got nothing since its last read: it pushes an | |
| 150 | - | // event on n, which the watcher may see, and requires it to be the next one. | |
| 151 | - | // Events reach a watcher in order, so anything sent before would show up first. | |
| 152 | - | func (w *watcher) quiet(t *testing.T, r *eventRig, n domain.RepoName) { | |
| 153 | - | t.Helper() | |
| 154 | - | r.feed <- r.pushEvent(n, sentinelOID, "refs/heads/sentinel") | |
| 155 | - | e := w.next(t) | |
| 156 | - | push, ok := e.Event.(domain.PushEvent) | |
| 157 | - | if !ok || len(push.Updates) != 1 || push.Updates[0].New != sentinelOID { | |
| 158 | - | t.Fatalf("expected nothing before the sentinel, got %+v", e) | |
| 154 | + | var ids []string | |
| 155 | + | for i := range 8 { | |
| 156 | + | if i > 0 { | |
| 157 | + | h.writeFile(wt, fmt.Sprintf("f%d.txt", i), []byte("x\n")) | |
| 158 | + | } | |
| 159 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 160 | + | ids = append(ids, w.next(t, watchWait).GetId()) | |
| 159 | 161 | } | |
| 160 | - | } | |
| 161 | 162 | ||
| 162 | - | func TestWatchLiveAndFiltered(t *testing.T) { | |
| 163 | - | r := newEventRig(t, 0) | |
| 164 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 165 | - | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 166 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 167 | - | defer cancel() | |
| 168 | - | ||
| 169 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 170 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"home"}}) | |
| 171 | - | scoped := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "other"), app.WatchParams{}) | |
| 172 | - | onlyPriv := r.watch(ctx, domain.Root(), app.WatchParams{Topics: []string{"repo:priv"}}) | |
| 173 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 4 }, 5*time.Second, time.Millisecond) | |
| 174 | - | ||
| 175 | - | r.push(priv, 1) | |
| 176 | - | r.push(pub, 2) | |
| 177 | - | ||
| 178 | - | // Anonymous and a token limited to another repository never see priv. | |
| 179 | - | for name, w := range map[string]*watcher{"anonymous": anon, "scoped": scoped} { | |
| 180 | - | e := w.next(t) | |
| 181 | - | assert.Equal(t, pub, e.Event.(domain.PushEvent).Repo, name) | |
| 182 | - | w.quiet(t, r, pub) | |
| 163 | + | // ids[2] is out of the ring (the last four are ids[4:]). | |
| 164 | + | r := h.watch(api, ids[2]) | |
| 165 | + | gap := r.next(t, watchWait) | |
| 166 | + | assert.NotNil(t, gap.GetGap()) | |
| 167 | + | assert.Empty(t, gap.GetId()) | |
| 168 | + | for _, want := range ids[4:] { | |
| 169 | + | assert.Equal(t, want, r.next(t, watchWait).GetId()) | |
| 183 | 170 | } | |
| 184 | - | first, second := root.next(t), root.next(t) | |
| 185 | - | assert.Equal(t, priv, first.Event.(domain.PushEvent).Repo) | |
| 186 | - | assert.Equal(t, pub, second.Event.(domain.PushEvent).Repo) | |
| 187 | - | assert.NotEmpty(t, first.ID) | |
| 188 | - | assert.NotEqual(t, first.ID, second.ID) | |
| 189 | - | ||
| 190 | - | assert.Equal(t, priv, onlyPriv.next(t).Event.(domain.PushEvent).Repo) | |
| 191 | - | onlyPriv.quiet(t, r, priv) | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | func TestWatchRechecksAccessPerEvent(t *testing.T) { | |
| 195 | - | r := newEventRig(t, 0) | |
| 196 | - | n := name(t, "flip") | |
| 197 | - | vis := domain.VisibilityPublic | |
| 198 | - | var mu sync.Mutex | |
| 199 | - | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 200 | - | mu.Lock() | |
| 201 | - | defer mu.Unlock() | |
| 202 | - | return domain.Repo{Name: n, Visibility: vis, CreatedAt: now}, nil | |
| 203 | - | }) | |
| 204 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 205 | - | defer cancel() | |
| 206 | - | w := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 207 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 208 | - | ||
| 209 | - | r.push(n, 1) | |
| 210 | - | w.next(t) | |
| 211 | 171 | ||
| 212 | - | mu.Lock() | |
| 213 | - | vis = domain.VisibilityPrivate | |
| 214 | - | mu.Unlock() | |
| 215 | - | r.push(n, 2) | |
| 216 | - | w.quiet(t, r, r.repo("open", domain.VisibilityPublic)) // push 2 is not delivered to anonymous | |
| 172 | + | // The last buffered id still resumes without a gap. | |
| 173 | + | ok := h.watch(api, ids[4]) | |
| 174 | + | assert.Equal(t, ids[5], ok.next(t, watchWait).GetId()) | |
| 217 | 175 | } | |
| 218 | 176 | ||
| 219 | - | func TestWatchResumeAndGap(t *testing.T) { | |
| 220 | - | const size = 8 | |
| 221 | - | r := newEventRig(t, size) | |
| 222 | - | n := r.repo("pub", domain.VisibilityPublic) | |
| 223 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 224 | - | defer cancel() | |
| 225 | - | ||
| 226 | - | // Record the ids of 20 events through a first watcher. | |
| 227 | - | first := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 228 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 229 | - | // One at a time: this watcher must not fall behind the small ring. | |
| 230 | - | var ids []string | |
| 231 | - | for i := range 20 { | |
| 232 | - | r.push(n, i) | |
| 233 | - | ids = append(ids, first.next(t).ID) | |
| 177 | + | // AC5: private events never reach anonymous callers, nor tokens that may not | |
| 178 | + | // read the repository; repo topics follow the same rule. | |
| 179 | + | func TestAC5NoPrivateEventsToAnonymous(t *testing.T) { | |
| 180 | + | h := newHarness(t, harnessOpts{}) | |
| 181 | + | h.seed("pub", domain.VisibilityPublic) | |
| 182 | + | h.seed("priv", domain.VisibilityPrivate) | |
| 183 | + | other := h.token(scopes(domain.ScopeRepoRead), "pub") | |
| 184 | + | reader := h.token(scopes(domain.ScopeRepoRead), "priv") | |
| 185 | + | ||
| 186 | + | anon := h.watch(h.api(""), "") | |
| 187 | + | scoped := h.watch(h.api(other), "") | |
| 188 | + | rd := h.watch(h.api(reader), "") | |
| 189 | + | root := h.watch(h.api(adminToken), "") | |
| 190 | + | ||
| 191 | + | // A private repository's topic is not found for those who cannot read it. | |
| 192 | + | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 193 | + | assert.Equal(t, "not_found", codeName(watchErr(h.ctx, h.api(other), "repo:priv"))) | |
| 194 | + | assert.Equal(t, codeName(watchErr(h.ctx, h.api(""), "repo:nothing")), codeName(watchErr(h.ctx, h.api(""), "repo:priv"))) | |
| 195 | + | ||
| 196 | + | wt := h.workTree("main", "a.txt") | |
| 197 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 198 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main") | |
| 199 | + | // Metadata events too. | |
| 200 | + | _, err := h.api(adminToken).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{ | |
| 201 | + | Repo: "priv", Description: "secret plans", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, | |
| 202 | + | })) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | ||
| 205 | + | // The reader of priv sees its push and its update, and pub's push. | |
| 206 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetPushed().GetRepo()) | |
| 207 | + | assert.Equal(t, "pub", rd.next(t, watchWait).GetPushed().GetRepo(), "pub is public") | |
| 208 | + | assert.Equal(t, "secret plans", rd.next(t, watchWait).GetRepoUpdated().GetRepo().GetDescription()) | |
| 209 | + | // Root sees everything in order. | |
| 210 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 211 | + | assert.Equal(t, "pub", root.next(t, watchWait).GetPushed().GetRepo()) | |
| 212 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoUpdated().GetRepo().GetName()) | |
| 213 | + | ||
| 214 | + | // Deleting reaches those who could read the repository before, not others. | |
| 215 | + | _, err = h.api(adminToken).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "priv"})) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | assert.Equal(t, "priv", rd.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 218 | + | assert.Equal(t, "priv", root.next(t, watchWait).GetRepoDeleted().GetRepo()) | |
| 219 | + | ||
| 220 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 221 | + | ev := w.next(t, watchWait) | |
| 222 | + | assert.Equal(t, "pub", ev.GetPushed().GetRepo(), name) | |
| 234 | 223 | } | |
| 235 | - | require.Len(t, ids, 20) | |
| 236 | - | ||
| 237 | - | // The subtests that read the state of the ring come first: the ones that | |
| 238 | - | // need a sentinel add events to it. | |
| 239 | - | t.Run("an id that fell out of the buffer gives a gap, then the oldest buffered event", func(t *testing.T) { | |
| 240 | - | // 20 events were taken, the buffer holds the last 8: ids[12] is the | |
| 241 | - | // oldest still present; ids[0] is long gone. | |
| 242 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[0]}) | |
| 243 | - | gap := w.next(t) | |
| 244 | - | assert.True(t, gap.Gap) | |
| 245 | - | assert.Empty(t, gap.ID) | |
| 246 | - | oldest := w.next(t) | |
| 247 | - | assert.False(t, oldest.Gap) | |
| 248 | - | assert.Equal(t, ids[12], oldest.ID) | |
| 249 | - | }) | |
| 250 | - | ||
| 251 | - | t.Run("an unknown id gives a gap", func(t *testing.T) { | |
| 252 | - | for _, id := range []string{"nonsense", "zz-1", "-", "0-0"} { | |
| 253 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: id}) | |
| 254 | - | assert.True(t, w.next(t).Gap, id) | |
| 255 | - | } | |
| 256 | - | }) | |
| 257 | - | ||
| 258 | - | t.Run("an id of a later run is a gap, not a newer event", func(t *testing.T) { | |
| 259 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: "otherboot-999"}) | |
| 260 | - | assert.True(t, w.next(t).Gap) | |
| 261 | - | }) | |
| 262 | - | ||
| 263 | - | t.Run("resume replays the buffered events after the id, then goes live", func(t *testing.T) { | |
| 264 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[16]}) | |
| 265 | - | for _, want := range ids[17:] { | |
| 266 | - | e := w.next(t) | |
| 267 | - | assert.False(t, e.Gap) | |
| 268 | - | assert.Equal(t, want, e.ID) | |
| 269 | - | } | |
| 270 | - | // Nothing else was replayed: the next event is the one pushed now. | |
| 271 | - | r.push(n, 98) | |
| 272 | - | live := w.next(t) | |
| 273 | - | assert.False(t, live.Gap) | |
| 274 | - | assert.NotContains(t, ids, live.ID) | |
| 275 | - | ids = append(ids, live.ID) | |
| 276 | - | }) | |
| 277 | - | ||
| 278 | - | t.Run("resume at the last event replays nothing, then goes live", func(t *testing.T) { | |
| 279 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{ResumeAfter: ids[len(ids)-1]}) | |
| 280 | - | r.push(n, 99) | |
| 281 | - | e := w.next(t) | |
| 282 | - | assert.False(t, e.Gap) | |
| 283 | - | assert.NotContains(t, ids, e.ID, "the first event is the new one, nothing was replayed") | |
| 284 | - | }) | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | func TestWatchTopicErrors(t *testing.T) { | |
| 288 | - | r := newEventRig(t, 0) | |
| 289 | - | r.repo("pub", domain.VisibilityPublic) | |
| 290 | - | r.repo("priv", domain.VisibilityPrivate) | |
| 291 | - | r.meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound).Maybe() | |
| 292 | - | noop := func(app.WatchEvent) error { return nil } | |
| 293 | - | ctx := context.Background() | |
| 294 | - | ||
| 295 | - | for topic, want := range map[string]error{ | |
| 296 | - | "nope": domain.ErrInvalid, | |
| 297 | - | "repo:Bad": domain.ErrInvalidName, | |
| 298 | - | "repo:": domain.ErrInvalidName, | |
| 299 | - | "repo:gone": domain.ErrNotFound, | |
| 300 | - | "repo:priv": domain.ErrNotFound, // hidden from anonymous | |
| 301 | - | "repo:pub:foo": domain.ErrInvalidName, | |
| 302 | - | } { | |
| 303 | - | err := r.events.Watch(ctx, domain.Anonymous(), app.WatchParams{Topics: []string{topic}}, noop) | |
| 304 | - | assert.ErrorIs(t, err, want, topic) | |
| 224 | + | // Nothing about priv came before the sentinel, for either of them. | |
| 225 | + | _, err = h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("sentinel"), Visibility: domain.VisibilityPublic}) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | for name, w := range map[string]*stream{"anonymous": anon, "scoped": scoped} { | |
| 228 | + | assert.Equal(t, "sentinel", w.next(t, watchWait).GetRepoCreated().GetRepo().GetName(), name) | |
| 305 | 229 | } | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | func TestWatchEndsCleanly(t *testing.T) { | |
| 309 | - | t.Run("on context cancellation", func(t *testing.T) { | |
| 310 | - | r := newEventRig(t, 0) | |
| 311 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 312 | - | w := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 313 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 314 | - | cancel() | |
| 315 | - | select { | |
| 316 | - | case err := <-w.done: | |
| 317 | - | assert.NoError(t, err) | |
| 318 | - | case <-time.After(5 * time.Second): | |
| 319 | - | t.Fatal("watch did not end") | |
| 320 | - | } | |
| 321 | - | assert.Zero(t, r.events.Watchers()) | |
| 322 | - | }) | |
| 323 | - | ||
| 324 | - | t.Run("on Close", func(t *testing.T) { | |
| 325 | - | r := newEventRig(t, 0) | |
| 326 | - | w := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |
| 327 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 328 | - | r.events.Close(context.Background()) | |
| 329 | - | r.events.Close(context.Background()) // twice is fine | |
| 330 | - | select { | |
| 331 | - | case err := <-w.done: | |
| 332 | - | assert.NoError(t, err) | |
| 333 | - | case <-time.After(5 * time.Second): | |
| 334 | - | t.Fatal("watch did not end") | |
| 335 | - | } | |
| 336 | - | }) | |
| 337 | 230 | ||
| 338 | - | t.Run("a failing send ends the watch with its error", func(t *testing.T) { | |
| 339 | - | r := newEventRig(t, 0) | |
| 340 | - | n := r.repo("pub", domain.VisibilityPublic) | |
| 341 | - | boom := errors.New("client went away") | |
| 342 | - | done := make(chan error, 1) | |
| 343 | - | go func() { | |
| 344 | - | done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(app.WatchEvent) error { return boom }) | |
| 345 | - | }() | |
| 346 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 347 | - | r.push(n, 1) | |
| 348 | - | assert.ErrorIs(t, <-done, boom) | |
| 349 | - | }) | |
| 231 | + | // Resuming does not replay private events to anonymous either: after a | |
| 232 | + | // gap, the creation and the push of pub, the sentinels the watchers above | |
| 233 | + | // saw, and nothing about priv. | |
| 234 | + | r := h.watch(h.api(""), "bogus") | |
| 235 | + | assert.NotNil(t, r.next(t, watchWait).GetGap()) | |
| 236 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 237 | + | assert.Equal(t, "pub", r.next(t, watchWait).GetPushed().GetRepo()) | |
| 238 | + | assert.Equal(t, "sentinel", r.next(t, watchWait).GetRepoCreated().GetRepo().GetName()) | |
| 239 | + | h.sentinel(t, r, "s-resumed") | |
| 350 | 240 | } | |
| 351 | 241 | ||
| 352 | - | func TestWatchSlowWatcherGetsAGap(t *testing.T) { | |
| 353 | - | const size = 4 | |
| 354 | - | r := newEventRig(t, size) | |
| 355 | - | n := r.repo("pub", domain.VisibilityPublic) | |
| 356 | - | ||
| 357 | - | release := make(chan struct{}) | |
| 358 | - | var mu sync.Mutex | |
| 359 | - | var got []app.WatchEvent | |
| 360 | - | done := make(chan error, 1) | |
| 361 | - | blocked := make(chan struct{}) | |
| 362 | - | first := true | |
| 363 | - | go func() { | |
| 364 | - | done <- r.events.Watch(context.Background(), domain.Root(), app.WatchParams{}, func(e app.WatchEvent) error { | |
| 365 | - | if first { // block on the first event while the ring turns over | |
| 366 | - | first = false | |
| 367 | - | close(blocked) | |
| 368 | - | <-release | |
| 369 | - | } | |
| 370 | - | mu.Lock() | |
| 371 | - | got = append(got, e) | |
| 372 | - | mu.Unlock() | |
| 373 | - | return nil | |
| 374 | - | }) | |
| 375 | - | }() | |
| 376 | - | // A second, fast watcher tells when the use case has taken every event: | |
| 377 | - | // it receives them in order. | |
| 378 | - | fast := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |
| 379 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |
| 380 | - | r.push(n, 0) | |
| 381 | - | <-blocked | |
| 382 | - | for i := 1; i < 30; i++ { | |
| 383 | - | r.push(n, i) | |
| 384 | - | } | |
| 385 | - | // The ring holds four events: the fast watcher may get a gap, but it ends | |
| 386 | - | // on the last push, which proves the use case took all of them. | |
| 387 | - | last := fmt.Sprintf("%040x", 29+1) | |
| 388 | - | for { | |
| 389 | - | e := fast.next(t) | |
| 390 | - | if p, ok := e.Event.(domain.PushEvent); ok && p.Updates[0].New == last { | |
| 391 | - | break | |
| 392 | - | } | |
| 393 | - | } | |
| 394 | - | close(release) | |
| 395 | - | ||
| 396 | - | require.Eventually(t, func() bool { | |
| 397 | - | mu.Lock() | |
| 398 | - | defer mu.Unlock() | |
| 399 | - | return len(got) >= 2 | |
| 400 | - | }, 5*time.Second, time.Millisecond) | |
| 401 | - | mu.Lock() | |
| 402 | - | defer mu.Unlock() | |
| 403 | - | hasGap := false | |
| 404 | - | for _, e := range got[1:] { | |
| 405 | - | hasGap = hasGap || e.Gap | |
| 406 | - | } | |
| 407 | - | assert.True(t, hasGap, "events were lost while the watcher was blocked: %+v", got) | |
| 242 | + | // AC5: a repo topic only carries that repository; a revoked token cannot | |
| 243 | + | // start a new watch. | |
| 244 | + | func TestAC5RepoTopic(t *testing.T) { | |
| 245 | + | h := newHarness(t, harnessOpts{}) | |
| 246 | + | h.seed("a", domain.VisibilityPublic) | |
| 247 | + | h.seed("b", domain.VisibilityPublic) | |
| 248 | + | w := h.watch(h.api(""), "", "repo:a") | |
| 249 | + | ||
| 250 | + | wt := h.workTree("main", "x.txt") | |
| 251 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/b.git"), "main") | |
| 252 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 253 | + | assert.Equal(t, "a", w.next(t, watchWait).GetPushed().GetRepo()) | |
| 254 | + | // The push to b was not delivered: the next event is the next push to a. | |
| 255 | + | h.writeFile(wt, "y.txt", []byte("y\n")) | |
| 256 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/a.git"), "main") | |
| 257 | + | assert.Equal(t, h.bareGit("a", "rev-parse", "main"), w.next(t, watchWait).GetPushed().GetRefUpdates()[0].GetNewId()) | |
| 408 | 258 | } | |
| 409 | 259 | ||
| 410 | - | func TestEventsRepoPayloads(t *testing.T) { | |
| 411 | - | r := newEventRig(t, 0) | |
| 412 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 413 | - | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 414 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 415 | - | defer cancel() | |
| 416 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 417 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 418 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |
| 419 | - | ||
| 420 | - | r.feed <- domain.RepoCreated{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |
| 421 | - | r.feed <- domain.RepoUpdated{Repo: pub, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPublic, CreatedAt: now} | |
| 422 | - | // A deleted repository cannot be read any more: its visibility decides. | |
| 423 | - | r.feed <- domain.RepoDeleted{Repo: priv, Visibility: domain.VisibilityPrivate, At: now} | |
| 424 | - | r.feed <- domain.RepoDeleted{Repo: pub, Visibility: domain.VisibilityPublic, At: now} | |
| 425 | - | ||
| 426 | - | for _, w := range []*watcher{anon, root} { | |
| 427 | - | created := w.next(t) | |
| 428 | - | require.NotNil(t, created.Repo) | |
| 429 | - | assert.Equal(t, "main", created.Repo.DefaultBranch) | |
| 430 | - | assert.Equal(t, pub, created.Repo.Name) | |
| 431 | - | require.NotNil(t, w.next(t).Repo) | |
| 260 | + | // The stream ends cleanly when the client cancels, and Watch counts go back to zero. | |
| 261 | + | func TestWatchEndsOnCancel(t *testing.T) { | |
| 262 | + | h := newHarness(t, harnessOpts{}) | |
| 263 | + | w := h.watch(h.api(""), "") | |
| 264 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 265 | + | w.cancel() | |
| 266 | + | select { | |
| 267 | + | case <-w.done: | |
| 268 | + | case <-time.After(5 * watchWait): | |
| 269 | + | t.Fatal("the stream did not end") | |
| 432 | 270 | } | |
| 433 | - | assert.Equal(t, priv, root.next(t).Event.(domain.RepoDeleted).Repo) | |
| 434 | - | assert.Equal(t, pub, root.next(t).Event.(domain.RepoDeleted).Repo) | |
| 435 | - | assert.Equal(t, pub, anon.next(t).Event.(domain.RepoDeleted).Repo, "the private deletion was skipped") | |
| 436 | - | anon.quiet(t, r, pub) | |
| 271 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| 437 | 272 | } | |
| 438 | 273 | ||
| 439 | - | // tokenWith builds the principal of a stored token with an id and an expiry. | |
| 440 | - | func tokenWith(id string, expires time.Time, repos ...string) domain.Principal { | |
| 441 | - | tok := domain.Token{ID: id, Scopes: []domain.Scope{domain.ScopeRepoRead}, ExpiresAt: expires} | |
| 442 | - | for _, r := range repos { | |
| 443 | - | n, err := domain.ParseRepoName(r) | |
| 274 | + | // Shutdown ends the streams cleanly: the client sees the stream end without | |
| 275 | + | // an error and the server exits 0 well before the shutdown timeout. | |
| 276 | + | func TestServeShutdownEndsWatchStreams(t *testing.T) { | |
| 277 | + | requireGit(t) | |
| 278 | + | data := t.TempDir() | |
| 279 | + | addr := freeAddr(t) | |
| 280 | + | cmd := exec.Command(binary(t), "serve") | |
| 281 | + | cmd.Env = append(baseEnv(), | |
| 282 | + | "ERMITE_ADDR="+addr, "ERMITE_DATA_DIR="+data, "ERMITE_PUBLIC_URL=http://"+addr, | |
| 283 | + | "ERMITE_ADMIN_TOKEN="+adminToken, "ERMITE_LOG_LEVEL=debug", "ERMITE_LOG_FORMAT=json", | |
| 284 | + | ) | |
| 285 | + | var logs syncBuffer | |
| 286 | + | cmd.Stdout, cmd.Stderr = &logs, &logs | |
| 287 | + | require.NoError(t, cmd.Start()) | |
| 288 | + | exited := make(chan error, 1) | |
| 289 | + | go func() { exited <- cmd.Wait() }() | |
| 290 | + | t.Cleanup(func() { _ = cmd.Process.Kill() }) | |
| 291 | + | ||
| 292 | + | base := "http://" + addr | |
| 293 | + | require.Eventually(t, func() bool { | |
| 294 | + | resp, err := http.Get(base + "/-/health") | |
| 444 | 295 | if err != nil { | |
| 445 | - | panic(err) | |
| 296 | + | return false | |
| 446 | 297 | } | |
| 447 | - | tok.Repos = append(tok.Repos, n) | |
| 448 | - | } | |
| 449 | - | return domain.TokenPrincipal(tok) | |
| 450 | - | } | |
| 298 | + | _ = resp.Body.Close() | |
| 299 | + | return resp.StatusCode == 200 | |
| 300 | + | }, 15*time.Second, 50*time.Millisecond, "%s", logs.String()) | |
| 451 | 301 | ||
| 452 | - | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 453 | - | // Asking at every turn keeps the test independent of the clock. | |
| 454 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1}) | |
| 455 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 456 | - | priv := r.repo("priv", domain.VisibilityPrivate) | |
| 457 | - | p := tokenWith("tok", time.Time{}) | |
| 302 | + | c := newClients(base+"/-/api", adminToken) | |
| 458 | 303 | ctx, cancel := context.WithCancel(context.Background()) | |
| 459 | 304 | defer cancel() | |
| 305 | + | st, err := c.event.Watch(ctx, connect.NewRequest(&pb.WatchRequest{})) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | ended := make(chan error, 1) | |
| 308 | + | go func() { | |
| 309 | + | for st.Receive() { | |
| 310 | + | } | |
| 311 | + | ended <- st.Err() | |
| 312 | + | }() | |
| 460 | 313 | ||
| 461 | - | w := r.watch(ctx, p, app.WatchParams{}) | |
| 462 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 463 | - | r.push(pub, 1) | |
| 464 | - | w.next(t) | |
| 314 | + | // The server version comes from the real binary. | |
| 315 | + | v, err := c.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 316 | + | require.NoError(t, err) | |
| 317 | + | assert.NotEmpty(t, v.Msg.GetVersion()) | |
| 465 | 318 | ||
| 466 | - | r.auth.revoke("tok") | |
| 467 | - | r.push(priv, 2) // would be delivered to a token that may read priv | |
| 468 | - | select { | |
| 469 | - | case err := <-w.done: | |
| 470 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 471 | - | case <-time.After(5 * time.Second): | |
| 472 | - | t.Fatal("the watch of a revoked token did not end") | |
| 473 | - | } | |
| 319 | + | start := time.Now() | |
| 320 | + | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |
| 474 | 321 | select { | |
| 475 | - | case e := <-w.got: | |
| 476 | - | t.Fatalf("an event was sent after the revocation: %+v", e) | |
| 477 | - | default: | |
| 322 | + | case err := <-ended: | |
| 323 | + | assert.NoError(t, err, "the stream ends cleanly") | |
| 324 | + | case <-time.After(15 * watchWait): | |
| 325 | + | t.Fatalf("the stream did not end\n%s", logs.String()) | |
| 478 | 326 | } | |
| 479 | - | } | |
| 480 | - | ||
| 481 | - | func TestWatchEndsWhenTheTokenExpires(t *testing.T) { | |
| 482 | - | r := newEventRig(t, 0) | |
| 483 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 484 | - | // The rig's clock stands at now: this token expired at now. | |
| 485 | - | p := tokenWith("tok", now) | |
| 486 | - | w := r.watch(context.Background(), p, app.WatchParams{}) | |
| 487 | - | r.push(pub, 1) // whether or not the watch had started, it ends unauthenticated | |
| 488 | 327 | select { | |
| 489 | - | case err := <-w.done: | |
| 490 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 491 | - | case <-time.After(5 * time.Second): | |
| 492 | - | t.Fatal("the watch of an expired token did not end") | |
| 328 | + | case err := <-exited: | |
| 329 | + | require.NoError(t, err, "%s", logs.String()) | |
| 330 | + | case <-time.After(15 * watchWait): | |
| 331 | + | t.Fatalf("the server did not exit\n%s", logs.String()) | |
| 493 | 332 | } | |
| 494 | - | assert.Empty(t, w.got) | |
| 333 | + | assert.Less(t, time.Since(start), 8*time.Second, "shutdown did not wait for its timeout") | |
| 334 | + | assert.NotContains(t, logs.String(), `"level":"error"`, "no error logged:\n%s", logs.String()) | |
| 335 | + | assert.False(t, errors.Is(err, context.Canceled)) | |
| 495 | 336 | } | |
| 496 | 337 | ||
| 497 | - | func TestWatchRevalidatesIdleWatchersAndCachesTheAnswer(t *testing.T) { | |
| 498 | - | t.Run("a quiet forge still notices a revocation", func(t *testing.T) { | |
| 499 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: 10 * time.Millisecond}) | |
| 500 | - | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | |
| 501 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 502 | - | r.auth.revoke("tok") | |
| 503 | - | select { | |
| 504 | - | case err := <-w.done: | |
| 505 | - | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 506 | - | case <-time.After(5 * time.Second): | |
| 507 | - | t.Fatal("an idle watch of a revoked token did not end") | |
| 508 | - | } | |
| 338 | + | // A token that is revoked while its Watch is open ends the stream with | |
| 339 | + | // unauthenticated, and the private events that follow never reach it. | |
| 340 | + | func TestWatchEndsWhenTheTokenIsRevoked(t *testing.T) { | |
| 341 | + | h := newHarness(t, harnessOpts{revalidateEvery: 20 * time.Millisecond}) | |
| 342 | + | h.seed("priv", domain.VisibilityPrivate) | |
| 343 | + | created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{ | |
| 344 | + | Label: "watcher", Scopes: scopes(domain.ScopeRepoRead), | |
| 509 | 345 | }) | |
| 346 | + | require.NoError(t, err) | |
| 347 | + | w := h.watch(h.api(created.Secret), "") | |
| 348 | + | wt := h.workTree("main", "a.txt") | |
| 349 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 350 | + | assert.Equal(t, "priv", w.next(t, 5*watchWait).GetPushed().GetRepo(), "the token may read priv") | |
| 510 | 351 | ||
| 511 | - | t.Run("the answer is kept between checks", func(t *testing.T) { | |
| 512 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: time.Hour}) | |
| 513 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 514 | - | w := r.watch(context.Background(), tokenWith("tok", time.Time{}), app.WatchParams{}) | |
| 515 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 1 }, 5*time.Second, time.Millisecond) | |
| 516 | - | for i := range 20 { | |
| 517 | - | r.push(pub, i) | |
| 518 | - | } | |
| 519 | - | for range 20 { | |
| 520 | - | w.next(t) | |
| 521 | - | } | |
| 522 | - | assert.Zero(t, r.auth.count(), "no store lookup within the interval") | |
| 523 | - | }) | |
| 352 | + | require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID)) | |
| 353 | + | h.writeFile(wt, "b.txt", []byte("b\n")) | |
| 354 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 524 | 355 | ||
| 525 | - | t.Run("root and anonymous never ask", func(t *testing.T) { | |
| 526 | - | r := newEventRigConfig(t, app.EventsConfig{RevalidateEvery: -1}) | |
| 527 | - | pub := r.repo("pub", domain.VisibilityPublic) | |
| 528 | - | a := r.watch(context.Background(), domain.Anonymous(), app.WatchParams{}) | |
| 529 | - | b := r.watch(context.Background(), domain.Root(), app.WatchParams{}) | |
| 530 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 2 }, 5*time.Second, time.Millisecond) | |
| 531 | - | r.push(pub, 1) | |
| 532 | - | a.next(t) | |
| 533 | - | b.next(t) | |
| 534 | - | assert.Zero(t, r.auth.count()) | |
| 535 | - | }) | |
| 356 | + | select { | |
| 357 | + | case <-w.done: | |
| 358 | + | case <-time.After(5 * watchWait): | |
| 359 | + | t.Fatal("the stream of a revoked token did not end") | |
| 360 | + | } | |
| 361 | + | assert.Equal(t, "unauthenticated", codeName(w.err)) | |
| 362 | + | for ev := range w.events { | |
| 363 | + | t.Fatalf("an event reached the revoked token: %v", ev) | |
| 364 | + | } | |
| 536 | 365 | } | |
| 537 | 366 | ||
| 367 | + | // Watch refuses too many topics, and too many streams from one client. | |
| 538 | 368 | func TestWatchLimits(t *testing.T) { | |
| 539 | - | noop := func(app.WatchEvent) error { return nil } | |
| 540 | - | ||
| 541 | - | t.Run("too many topics", func(t *testing.T) { | |
| 542 | - | r := newEventRig(t, 0) | |
| 543 | - | r.repo("pub", domain.VisibilityPublic) | |
| 544 | - | ok := make([]string, app.MaxWatchTopics) | |
| 545 | - | for i := range ok { | |
| 546 | - | ok[i] = "home" | |
| 547 | - | } | |
| 548 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 549 | - | cancel() // returns at once once the topics are accepted | |
| 550 | - | assert.NoError(t, r.events.Watch(ctx, domain.Root(), app.WatchParams{Topics: ok}, noop)) | |
| 551 | - | err := r.events.Watch(context.Background(), domain.Root(), app.WatchParams{Topics: append(ok, "home")}, noop) | |
| 552 | - | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 553 | - | }) | |
| 554 | - | ||
| 555 | - | t.Run("total watchers", func(t *testing.T) { | |
| 556 | - | r := newEventRig(t, 0) | |
| 557 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 558 | - | defer cancel() | |
| 559 | - | for range app.MaxWatchers { | |
| 560 | - | r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 561 | - | } | |
| 562 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchers }, 5*time.Second, time.Millisecond) | |
| 563 | - | err := r.events.Watch(ctx, domain.Root(), app.WatchParams{}, noop) | |
| 564 | - | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 565 | - | assert.Equal(t, app.MaxWatchers, r.events.Watchers(), "a refused watch is not counted") | |
| 566 | - | ||
| 567 | - | cancel() // all end; a new one is accepted again | |
| 568 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| 569 | - | again, stop := context.WithCancel(context.Background()) | |
| 570 | - | stop() | |
| 571 | - | assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{}, noop)) | |
| 572 | - | }) | |
| 573 | - | ||
| 574 | - | t.Run("watchers per client", func(t *testing.T) { | |
| 575 | - | r := newEventRig(t, 0) | |
| 576 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 577 | - | defer cancel() | |
| 578 | - | for range app.MaxWatchersPerClient { | |
| 579 | - | r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}) | |
| 580 | - | } | |
| 581 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |
| 582 | - | ||
| 583 | - | err := r.events.Watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop) | |
| 584 | - | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 585 | - | ||
| 586 | - | // Another client, and callers in process, are not affected. | |
| 587 | - | r.watch(ctx, domain.Root(), app.WatchParams{Client: "198.51.100.2"}) | |
| 588 | - | r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 589 | - | require.Eventually(t, func() bool { return r.events.Watchers() == app.MaxWatchersPerClient+2 }, 5*time.Second, time.Millisecond) | |
| 590 | - | ||
| 591 | - | cancel() | |
| 592 | - | require.Eventually(t, func() bool { return r.events.Watchers() == 0 }, 5*time.Second, time.Millisecond) | |
| 593 | - | again, stop := context.WithCancel(context.Background()) | |
| 594 | - | stop() | |
| 595 | - | assert.NoError(t, r.events.Watch(again, domain.Root(), app.WatchParams{Client: "198.51.100.1"}, noop), "the slots were released") | |
| 596 | - | }) | |
| 597 | - | } | |
| 369 | + | h := newHarness(t, harnessOpts{}) | |
| 370 | + | api := h.api(adminToken) | |
| 598 | 371 | ||
| 599 | - | // repoAt declares a repository whose metadata the test changes. | |
| 600 | - | type liveRepo struct { | |
| 601 | - | mu sync.Mutex | |
| 602 | - | repo domain.Repo | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | func (l *liveRepo) set(f func(*domain.Repo)) { | |
| 606 | - | l.mu.Lock() | |
| 607 | - | defer l.mu.Unlock() | |
| 608 | - | f(&l.repo) | |
| 609 | - | } | |
| 610 | - | ||
| 611 | - | func (r *eventRig) liveRepo(s string, vis domain.Visibility, created time.Time) (*liveRepo, domain.RepoName) { | |
| 612 | - | n := name(r.t, s) | |
| 613 | - | l := &liveRepo{repo: domain.Repo{Name: n, Visibility: vis, CreatedAt: created}} | |
| 614 | - | r.meta.EXPECT().Get(mock.Anything, n).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) { | |
| 615 | - | l.mu.Lock() | |
| 616 | - | defer l.mu.Unlock() | |
| 617 | - | return l.repo, nil | |
| 618 | - | }).Maybe() | |
| 619 | - | return l, n | |
| 620 | - | } | |
| 372 | + | topics := make([]string, usecase.MaxWatchTopics+1) | |
| 373 | + | for i := range topics { | |
| 374 | + | topics[i] = "home" | |
| 375 | + | } | |
| 376 | + | assert.Equal(t, "invalid_argument", codeName(watchErr(h.ctx, api, topics...))) | |
| 621 | 377 | ||
| 622 | - | func TestEventsKeepTheVisibilityOfTheirTime(t *testing.T) { | |
| 623 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 378 | + | for range usecase.MaxWatchersPerClient { | |
| 379 | + | h.watch(api, "") | |
| 380 | + | } | |
| 381 | + | require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == usecase.MaxWatchersPerClient }, 5*time.Second, time.Millisecond) | |
| 382 | + | assert.Equal(t, "resource_exhausted", codeName(watchErr(h.ctx, api))) | |
| 383 | + | // A stream of another client address is not affected (the harness trusts | |
| 384 | + | // the loopback proxy only when configured: ask the use case directly). | |
| 385 | + | ctx, cancel := context.WithCancel(h.ctx) | |
| 624 | 386 | defer cancel() | |
| 625 | - | ||
| 626 | - | t.Run("a repository made public does not reveal what happened while it was private", func(t *testing.T) { | |
| 627 | - | r := newEventRig(t, 0) | |
| 628 | - | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | |
| 629 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 630 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |
| 631 | - | r.push(n, 1) | |
| 632 | - | r.push(n, 2) | |
| 633 | - | root.next(t) | |
| 634 | - | root.next(t) // both are buffered now | |
| 635 | - | ||
| 636 | - | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }) | |
| 637 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 638 | - | root.next(t) | |
| 639 | - | r.push(n, 3) // public era | |
| 640 | - | ||
| 641 | - | // An anonymous caller that resumes with an id nobody issued gets what | |
| 642 | - | // is buffered, filtered: the public update and the public push, not the | |
| 643 | - | // pushes of the private era. | |
| 644 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |
| 645 | - | assert.True(t, anon.next(t).Gap) | |
| 646 | - | upd := anon.next(t) | |
| 647 | - | require.IsType(t, domain.RepoUpdated{}, upd.Event) | |
| 648 | - | push := anon.next(t) | |
| 649 | - | require.IsType(t, domain.PushEvent{}, push.Event) | |
| 650 | - | assert.Equal(t, fmt.Sprintf("%040x", 3+1), push.Event.(domain.PushEvent).Updates[0].New) | |
| 651 | - | anon.quiet(t, r, n) | |
| 652 | - | }) | |
| 653 | - | ||
| 654 | - | t.Run("a repository deleted and created again does not inherit old events", func(t *testing.T) { | |
| 655 | - | r := newEventRig(t, 0) | |
| 656 | - | first := now.Add(-time.Hour) | |
| 657 | - | l, n := r.liveRepo("again", domain.VisibilityPublic, first) | |
| 658 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 659 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |
| 660 | - | r.push(n, 1) | |
| 661 | - | root.next(t) | |
| 662 | - | ||
| 663 | - | l.set(func(repo *domain.Repo) { repo.CreatedAt = now }) // deleted, created again | |
| 664 | - | r.push(n, 2) | |
| 665 | - | root.next(t) | |
| 666 | - | ||
| 667 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |
| 668 | - | assert.True(t, anon.next(t).Gap) | |
| 669 | - | e := anon.next(t) | |
| 670 | - | require.IsType(t, domain.PushEvent{}, e.Event) | |
| 671 | - | assert.Equal(t, fmt.Sprintf("%040x", 2+1), e.Event.(domain.PushEvent).Updates[0].New, "only the push to the new repository") | |
| 672 | - | anon.quiet(t, r, n) | |
| 673 | - | }) | |
| 674 | - | ||
| 675 | - | t.Run("a repository made private tells those who could read it, once", func(t *testing.T) { | |
| 676 | - | r := newEventRig(t, 0) | |
| 677 | - | l, n := r.liveRepo("hide", domain.VisibilityPublic, now) | |
| 678 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 679 | - | reader := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "hide"), app.WatchParams{}) | |
| 680 | - | other := r.watch(ctx, tokenPrincipal(domain.ScopeRepoRead, "elsewhere"), app.WatchParams{}) | |
| 681 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 682 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 4 }, 5*time.Second, time.Millisecond) | |
| 683 | - | ||
| 684 | - | l.set(func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPrivate }) | |
| 685 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic, CreatedAt: now} | |
| 686 | - | ||
| 687 | - | for name, w := range map[string]*watcher{"anonymous": anon, "reader": reader, "other": other, "root": root} { | |
| 688 | - | e := w.next(t) | |
| 689 | - | up, ok := e.Event.(domain.RepoUpdated) | |
| 690 | - | require.True(t, ok, name) | |
| 691 | - | assert.Equal(t, n, up.Repo, name) | |
| 692 | - | require.NotNil(t, e.Repo, name) | |
| 693 | - | assert.Equal(t, domain.VisibilityPrivate, e.Repo.Visibility, name) | |
| 694 | - | assert.Empty(t, e.Repo.Description, "nothing but the name and the visibility", name) | |
| 695 | - | } | |
| 696 | - | ||
| 697 | - | // Later events of the repository reach those who may read it now, only. | |
| 698 | - | r.push(n, 1) | |
| 699 | - | assert.IsType(t, domain.PushEvent{}, reader.next(t).Event) | |
| 700 | - | assert.IsType(t, domain.PushEvent{}, root.next(t).Event) | |
| 701 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 702 | - | other.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |
| 703 | - | }) | |
| 704 | - | ||
| 705 | - | t.Run("a private repository that never was public stays hidden", func(t *testing.T) { | |
| 706 | - | r := newEventRig(t, 0) | |
| 707 | - | _, n := r.liveRepo("closed", domain.VisibilityPrivate, now) | |
| 708 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 709 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |
| 710 | - | r.feed <- domain.RepoUpdated{Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate, CreatedAt: now} | |
| 711 | - | r.push(n, 1) | |
| 712 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 713 | - | }) | |
| 387 | + | registered := false | |
| 388 | + | err := h.forge.EventLog.Watch(ctx, domain.Root(), ports.WatchParams{ | |
| 389 | + | Client: "another-client", Ready: func() { registered = true; cancel() }, | |
| 390 | + | }, func(ports.WatchEvent) error { return nil }) | |
| 391 | + | require.NoError(t, err) | |
| 392 | + | assert.True(t, registered, "a watch of another client address is registered") | |
| 714 | 393 | } | |
| 715 | 394 | ||
| 716 | - | // TestPushEventsKeepTheirSnapshot: a push is told by the repository as it was | |
| 717 | - | // when published, not as the store says when the feeder gets to it. | |
| 718 | - | func TestPushEventsKeepTheirSnapshot(t *testing.T) { | |
| 719 | - | ctx, cancel := context.WithCancel(context.Background()) | |
| 720 | - | defer cancel() | |
| 721 | - | ||
| 722 | - | tests := []struct { | |
| 723 | - | name string | |
| 724 | - | swap func(repo *domain.Repo) | |
| 725 | - | rootSees bool // the repository is still the one the push is about | |
| 726 | - | }{ | |
| 727 | - | {"made public before the feeder appends", func(repo *domain.Repo) { repo.Visibility = domain.VisibilityPublic }, true}, | |
| 728 | - | {"recreated before the feeder appends", func(repo *domain.Repo) { | |
| 729 | - | repo.Visibility = domain.VisibilityPublic | |
| 730 | - | repo.CreatedAt = now.Add(time.Hour) | |
| 731 | - | }, false}, | |
| 732 | - | } | |
| 733 | - | for _, tt := range tests { | |
| 734 | - | t.Run(tt.name, func(t *testing.T) { | |
| 735 | - | r := newEventRig(t, 0) | |
| 736 | - | l, n := r.liveRepo("secret", domain.VisibilityPrivate, now) | |
| 737 | - | anon := r.watch(ctx, domain.Anonymous(), app.WatchParams{}) | |
| 738 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 739 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 2 }, 5*time.Second, time.Millisecond) | |
| 740 | - | ||
| 741 | - | // Published while private; the store changes before the feeder | |
| 742 | - | // can have looked at it: the event is built, then the state is | |
| 743 | - | // swapped, and only then is it handed to the feeder. | |
| 744 | - | ev := r.pushEvent(n, fmt.Sprintf("%040x", 1), "refs/heads/main") | |
| 745 | - | l.set(tt.swap) | |
| 746 | - | r.feed <- ev | |
| 747 | - | if tt.rootSees { | |
| 748 | - | root.next(t) | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | anon.quiet(t, r, r.repo("open", domain.VisibilityPublic)) | |
| 752 | - | ||
| 753 | - | late := r.watch(ctx, domain.Anonymous(), app.WatchParams{ResumeAfter: "bogus"}) | |
| 754 | - | assert.True(t, late.next(t).Gap) | |
| 755 | - | late.quiet(t, r, r.repo("open2", domain.VisibilityPublic)) | |
| 756 | - | }) | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | t.Run("a push of an unknown repository is told to nobody", func(t *testing.T) { | |
| 760 | - | r := newEventRig(t, 0) | |
| 761 | - | n := r.repo("ghost", domain.VisibilityPublic) | |
| 762 | - | root := r.watch(ctx, domain.Root(), app.WatchParams{}) | |
| 763 | - | require.Eventually(t, func() bool { return r.events.Watchers() >= 1 }, 5*time.Second, time.Millisecond) | |
| 764 | - | r.feed <- domain.PushEvent{Repo: n, Visibility: domain.VisibilityPublic, At: now} | |
| 765 | - | root.quiet(t, r, n) | |
| 766 | - | }) | |
| 395 | + | // sentinel proves that nothing reached the stream since the last event it | |
| 396 | + | // returned: it creates a public repository, which every watcher of "home" may | |
| 397 | + | // see, and requires its RepoCreated to be the next event. Events arrive in the | |
| 398 | + | // order they were published. | |
| 399 | + | func (h *harness) sentinel(t *testing.T, s *stream, name string) { | |
| 400 | + | t.Helper() | |
| 401 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 402 | + | require.NoError(t, err) | |
| 403 | + | ev := s.next(t, watchWait) | |
| 404 | + | assert.Equal(t, name, ev.GetRepoCreated().GetRepo().GetName(), "an event came before the sentinel: %v", ev) | |
| 767 | 405 | } | |
D internal/app/export_test.go +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package app | |
| 4 | - | ||
| 5 | - | import "bnema.dev/ermite/internal/domain" | |
| 6 | - | ||
| 7 | - | // Idle reports whether nothing is pending or running: tests wait for it | |
| 8 | - | // instead of sleeping. | |
| 9 | - | func (m *Maintainer) Idle() bool { | |
| 10 | - | m.mu.Lock() | |
| 11 | - | defer m.mu.Unlock() | |
| 12 | - | return len(m.entries) == 0 | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // AuthorOf exposes authorOf to the tests. | |
| 16 | - | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |
R cmd/ermite/forge.go → internal/app/forge.go +28 -26 renamed, 86% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -24,11 +24,11 @@ import ( | |||
| 24 | 24 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 25 | 25 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 26 | 26 | "bnema.dev/ermite/internal/adapters/web" | |
| 27 | - | "bnema.dev/ermite/internal/app" | |
| 28 | 27 | "bnema.dev/ermite/internal/clientlimit" | |
| 29 | 28 | "bnema.dev/ermite/internal/config" | |
| 30 | 29 | "bnema.dev/ermite/internal/domain" | |
| 31 | 30 | "bnema.dev/ermite/internal/ports" | |
| 31 | + | "bnema.dev/ermite/internal/usecase" | |
| 32 | 32 | ) | |
| 33 | 33 | ||
| 34 | 34 | // maxReceiveBytes bounds the body of a push, both at the HTTP handler and in | |
| @@ -40,13 +40,13 @@ type options struct { | |||
| 40 | 40 | // hookExe is the executable the hook scripts exec; empty means this | |
| 41 | 41 | // process's own executable. | |
| 42 | 42 | hookExe string | |
| 43 | - | // maintenanceDelay overrides app.MaintenanceDelay when positive. | |
| 43 | + | // maintenanceDelay overrides usecase.MaintenanceDelay when positive. | |
| 44 | 44 | maintenanceDelay time.Duration | |
| 45 | 45 | // maxBodyBytes overrides the limit of a push body when positive. | |
| 46 | 46 | maxBodyBytes int64 | |
| 47 | - | // eventBuffer overrides app.DefaultEventBuffer when positive. | |
| 47 | + | // eventBuffer overrides usecase.DefaultEventBuffer when positive. | |
| 48 | 48 | eventBuffer int | |
| 49 | - | // revalidateEvery overrides app.DefaultRevalidateEvery when not zero. | |
| 49 | + | // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero. | |
| 50 | 50 | revalidateEvery time.Duration | |
| 51 | 51 | } | |
| 52 | 52 | ||
| @@ -56,24 +56,24 @@ type forge struct { | |||
| 56 | 56 | store *kvstore.Store | |
| 57 | 57 | server *httpserver.Server | |
| 58 | 58 | hooks *githttp.HookServer | |
| 59 | - | maint *app.Maintainer | |
| 59 | + | maint *usecase.Maintainer | |
| 60 | 60 | git *gitcli.Git | |
| 61 | 61 | // reader is the ermite-git reader when ERMITE_GIT_READER selects it. | |
| 62 | 62 | reader *ermitegit.Adapter | |
| 63 | 63 | ||
| 64 | 64 | // The use cases and the API are exposed for tests and for the phases | |
| 65 | 65 | // that add the web. | |
| 66 | - | Repos *app.Repos | |
| 67 | - | Tokens *app.Tokens | |
| 68 | - | Auth *app.Auth | |
| 69 | - | Pushes *app.Pushes | |
| 70 | - | Browse *app.Browse | |
| 66 | + | Repos *usecase.Repos | |
| 67 | + | Tokens *usecase.Tokens | |
| 68 | + | Auth *usecase.Auth | |
| 69 | + | Pushes *usecase.Pushes | |
| 70 | + | Browse *usecase.Browse | |
| 71 | 71 | // MergeRequests and SigningKey serve MergeRequestService and | |
| 72 | 72 | // ServerService.GetSigningKey. | |
| 73 | - | MergeRequests *app.MergeRequests | |
| 74 | - | SigningKey *app.SigningKey | |
| 75 | - | Sessions *app.Sessions | |
| 76 | - | EventLog *app.Events | |
| 73 | + | MergeRequests *usecase.MergeRequests | |
| 74 | + | SigningKey *usecase.SigningKey | |
| 75 | + | Sessions *usecase.Sessions | |
| 76 | + | EventLog *usecase.Events | |
| 77 | 77 | API *rpc.API | |
| 78 | 78 | Bus ports.EventBus | |
| 79 | 79 | } | |
| @@ -139,7 +139,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 139 | 139 | ||
| 140 | 140 | clock := systemClock{} | |
| 141 | 141 | f.Bus = eventbus.New() | |
| 142 | - | f.Repos = app.NewRepos(store, git, f.Bus, clock) | |
| 142 | + | f.Repos = usecase.NewRepos(store, git, f.Bus, clock) | |
| 143 | 143 | report, err := f.Repos.Reconcile(ctx) | |
| 144 | 144 | if err != nil { | |
| 145 | 145 | return nil, fmt.Errorf("reconcile repositories: %w", err) | |
| @@ -150,10 +150,10 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 150 | 150 | Int("invalid_dirs", len(report.InvalidDirs)). | |
| 151 | 151 | Msg("repositories reconciled") | |
| 152 | 152 | ||
| 153 | - | f.Tokens = app.NewTokens(store.Tokens(), clock, store.Sessions()) | |
| 154 | - | f.Auth = app.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | |
| 155 | - | f.maint = app.NewMaintainer(ctx, git, opts.maintenanceDelay) | |
| 156 | - | f.Pushes = app.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | |
| 153 | + | f.Tokens = usecase.NewTokens(store.Tokens(), clock, store.Sessions()) | |
| 154 | + | f.Auth = usecase.NewAuth(cfg.AdminTokenHash, store.Tokens(), clock) | |
| 155 | + | f.maint = usecase.NewMaintainer(ctx, git, opts.maintenanceDelay) | |
| 156 | + | f.Pushes = usecase.NewPushes(store, f.Repos, git, f.Bus, clock, f.maint, f.Auth) | |
| 157 | 157 | ||
| 158 | 158 | exe := opts.hookExe | |
| 159 | 159 | if exe == "" { | |
| @@ -181,8 +181,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 181 | 181 | reader = f.reader | |
| 182 | 182 | } | |
| 183 | 183 | log.Info().Str("reader", cfg.GitReader).Msg("git reader selected") | |
| 184 | - | f.Browse = app.NewBrowse(store, reader) | |
| 185 | - | f.EventLog = app.NewEvents(ctx, f.Repos, f.Bus, clock, app.EventsConfig{ | |
| 184 | + | f.Browse = usecase.NewBrowse(store, reader) | |
| 185 | + | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | |
| 186 | 186 | Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery, | |
| 187 | 187 | }) | |
| 188 | 188 | ||
| @@ -196,14 +196,14 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 196 | 196 | return nil, err | |
| 197 | 197 | } | |
| 198 | 198 | committer := domain.Identity{Name: cfg.CommitterName, Email: cfg.CommitterEmail} | |
| 199 | - | f.SigningKey = app.NewSigningKey(signer, committer) | |
| 200 | - | f.MergeRequests = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 199 | + | f.SigningKey = usecase.NewSigningKey(signer, committer) | |
| 200 | + | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 201 | 201 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 202 | 202 | Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint, | |
| 203 | 203 | }) | |
| 204 | 204 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 205 | 205 | ||
| 206 | - | f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| 206 | + | f.Sessions, err = usecase.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| 207 | 207 | if err != nil { | |
| 208 | 208 | return nil, err | |
| 209 | 209 | } | |
| @@ -240,13 +240,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 240 | 240 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 241 | 241 | } | |
| 242 | 242 | warnSessionsNeedHTTPS(ctx, cfg.PublicURL) | |
| 243 | - | site, err := newWeb(cfg, f.API, f.Sessions, limiter) | |
| 243 | + | warnSharedClientKey(ctx, cfg.TrustedProxies, githttp.DefaultMaxPerClient) | |
| 244 | + | site, err := newWeb(cfg, f, limiter) | |
| 244 | 245 | if err != nil { | |
| 245 | 246 | return nil, fmt.Errorf("web: %w", err) | |
| 246 | 247 | } | |
| 247 | 248 | routes = append(routes, webRoutes(site)...) | |
| 248 | 249 | f.server = httpserver.New(httpserver.Config{ | |
| 249 | 250 | Addr: cfg.Addr, | |
| 251 | + | PublicURL: cfg.PublicURL, | |
| 250 | 252 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |
| 251 | 253 | Routes: routes, | |
| 252 | 254 | API: f.API.Handler(), | |
R cmd/ermite/gitreader_test.go → internal/app/gitreader_test.go +3 -3 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
| @@ -10,9 +10,9 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | - | "bnema.dev/ermite/internal/app" | |
| 14 | 13 | "bnema.dev/ermite/internal/config" | |
| 15 | 14 | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | // The suite runs with the reader of ERMITE_GIT_READER (ermite-git by default). | |
| @@ -28,7 +28,7 @@ func TestGitReaderOfTheForgeAndReadAfterPush(t *testing.T) { | |||
| 28 | 28 | assert.Equal(t, want, h.forge.cfg.GitReader) | |
| 29 | 29 | assert.Equal(t, want == config.GitReaderErmiteGit, h.forge.reader != nil, "the ermite-git adapter exists only for ermite-git") | |
| 30 | 30 | ||
| 31 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |
| 31 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ | |
| 32 | 32 | Name: h.name("fresh"), Visibility: domain.VisibilityPublic, | |
| 33 | 33 | }) | |
| 34 | 34 | require.NoError(t, err) | |
R cmd/ermite/hook.go → internal/app/hook.go +1 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/main_test.go → internal/app/main_test.go +2 -2 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -43,7 +43,7 @@ func binary(t *testing.T) string { | |||
| 43 | 43 | return | |
| 44 | 44 | } | |
| 45 | 45 | binPath = filepath.Join(dir, "ermite") | |
| 46 | - | out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput() | |
| 46 | + | out, err := exec.Command("go", "build", "-o", binPath, "bnema.dev/ermite/cmd/ermite").CombinedOutput() | |
| 47 | 47 | if err != nil { | |
| 48 | 48 | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | |
| 49 | 49 | } | |
D internal/app/maintenance_test.go +0 -187
| @@ -1,187 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package app_test | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "context" | |
| 7 | - | "errors" | |
| 8 | - | "runtime" | |
| 9 | - | "sync" | |
| 10 | - | "sync/atomic" | |
| 11 | - | "testing" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/stretchr/testify/assert" | |
| 15 | - | ||
| 16 | - | "bnema.dev/ermite/internal/app" | |
| 17 | - | "bnema.dev/ermite/internal/domain" | |
| 18 | - | "bnema.dev/ermite/internal/ports/mocks" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | func waitFor(t *testing.T, cond func() bool, what string) { | |
| 22 | - | t.Helper() | |
| 23 | - | deadline := time.Now().Add(5 * time.Second) | |
| 24 | - | for !cond() { | |
| 25 | - | if time.Now().After(deadline) { | |
| 26 | - | t.Fatalf("timeout waiting for %s", what) | |
| 27 | - | } | |
| 28 | - | time.Sleep(2 * time.Millisecond) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func TestMaintainerDebounces(t *testing.T) { | |
| 33 | - | ctx := context.Background() | |
| 34 | - | n := name(t, "tools") | |
| 35 | - | git := mocks.NewGitRepos(t) | |
| 36 | - | var runs atomic.Int32 | |
| 37 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |
| 38 | - | runs.Add(1) | |
| 39 | - | return nil | |
| 40 | - | }) | |
| 41 | - | m := app.NewMaintainer(ctx, git, 200*time.Millisecond) | |
| 42 | - | defer m.Close(ctx) | |
| 43 | - | ||
| 44 | - | for range 10 { | |
| 45 | - | m.Schedule(ctx, n) | |
| 46 | - | } | |
| 47 | - | assert.Equal(t, int32(0), runs.Load(), "waits for the delay") | |
| 48 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "one run") | |
| 49 | - | // Idle: nothing is pending or running, so no second run is coming. | |
| 50 | - | waitFor(t, m.Idle, "the maintainer to be idle") | |
| 51 | - | assert.Equal(t, int32(1), runs.Load(), "a burst makes one run") | |
| 52 | - | ||
| 53 | - | m.Schedule(ctx, n) | |
| 54 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "a second burst, a second run") | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func TestMaintainerOneRunAtATimePerRepo(t *testing.T) { | |
| 58 | - | ctx := context.Background() | |
| 59 | - | n, other := name(t, "tools"), name(t, "other") | |
| 60 | - | git := mocks.NewGitRepos(t) | |
| 61 | - | var concurrent, maxConcurrent, runs atomic.Int32 | |
| 62 | - | release := make(chan struct{}) | |
| 63 | - | var otherRan atomic.Bool | |
| 64 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |
| 65 | - | c := concurrent.Add(1) | |
| 66 | - | for { | |
| 67 | - | m := maxConcurrent.Load() | |
| 68 | - | if c <= m || maxConcurrent.CompareAndSwap(m, c) { | |
| 69 | - | break | |
| 70 | - | } | |
| 71 | - | } | |
| 72 | - | runs.Add(1) | |
| 73 | - | <-release | |
| 74 | - | concurrent.Add(-1) | |
| 75 | - | return nil | |
| 76 | - | }) | |
| 77 | - | git.EXPECT().Maintain(mockAny, other).RunAndReturn(func(context.Context, domain.RepoName) error { | |
| 78 | - | otherRan.Store(true) | |
| 79 | - | return nil | |
| 80 | - | }) | |
| 81 | - | m := app.NewMaintainer(ctx, git, 10*time.Millisecond) | |
| 82 | - | defer m.Close(ctx) | |
| 83 | - | ||
| 84 | - | m.Schedule(ctx, n) | |
| 85 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "first run to start") | |
| 86 | - | m.Schedule(ctx, n) // during the run: queues exactly one more | |
| 87 | - | m.Schedule(ctx, n) | |
| 88 | - | m.Schedule(ctx, other) | |
| 89 | - | waitFor(t, otherRan.Load, "another repository is not blocked") | |
| 90 | - | assert.Equal(t, int32(1), runs.Load(), "the second run waits for the first") | |
| 91 | - | ||
| 92 | - | release <- struct{}{} // finish first run | |
| 93 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "queued run") | |
| 94 | - | release <- struct{}{} | |
| 95 | - | waitFor(t, m.Idle, "the maintainer to be idle") | |
| 96 | - | assert.Equal(t, int32(2), runs.Load(), "exactly one queued run") | |
| 97 | - | assert.Equal(t, int32(1), maxConcurrent.Load()) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func TestMaintainerTellsTheReaderAfterARun(t *testing.T) { | |
| 101 | - | ctx := context.Background() | |
| 102 | - | n := name(t, "tools") | |
| 103 | - | for _, failure := range []error{nil, errors.New("boom")} { | |
| 104 | - | git := mocks.NewGitRepos(t) | |
| 105 | - | git.EXPECT().Maintain(mockAny, n).Return(failure) | |
| 106 | - | fresh := &freshener{} | |
| 107 | - | m := app.NewMaintainer(ctx, git, time.Millisecond).WithFreshener(fresh) | |
| 108 | - | m.Schedule(ctx, n) | |
| 109 | - | waitFor(t, func() bool { return len(fresh.seen()) == 1 }, "the reader to be told") | |
| 110 | - | m.Close(ctx) | |
| 111 | - | assert.Equal(t, []domain.RepoName{n}, fresh.seen()) | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func TestMaintainerFailureIsLoggedNotFatal(t *testing.T) { | |
| 116 | - | ctx := context.Background() | |
| 117 | - | n := name(t, "tools") | |
| 118 | - | git := mocks.NewGitRepos(t) | |
| 119 | - | var runs atomic.Int32 | |
| 120 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(context.Context, domain.RepoName) error { | |
| 121 | - | runs.Add(1) | |
| 122 | - | return errors.New("boom") | |
| 123 | - | }) | |
| 124 | - | m := app.NewMaintainer(ctx, git, 5*time.Millisecond) | |
| 125 | - | defer m.Close(ctx) | |
| 126 | - | m.Schedule(ctx, n) | |
| 127 | - | waitFor(t, func() bool { return runs.Load() == 1 }, "run") | |
| 128 | - | m.Schedule(ctx, n) | |
| 129 | - | waitFor(t, func() bool { return runs.Load() == 2 }, "later runs still happen") | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | func TestMaintainerClose(t *testing.T) { | |
| 133 | - | ctx := context.Background() | |
| 134 | - | n := name(t, "tools") | |
| 135 | - | ||
| 136 | - | t.Run("pending runs are dropped", func(t *testing.T) { | |
| 137 | - | git := mocks.NewGitRepos(t) // no Maintain expected | |
| 138 | - | m := app.NewMaintainer(ctx, git, time.Hour) | |
| 139 | - | m.Schedule(ctx, n) | |
| 140 | - | m.Close(ctx) | |
| 141 | - | m.Schedule(ctx, n) // after Close: ignored | |
| 142 | - | m.Close(ctx) // twice is fine | |
| 143 | - | }) | |
| 144 | - | ||
| 145 | - | t.Run("a run in progress is cancelled and awaited", func(t *testing.T) { | |
| 146 | - | git := mocks.NewGitRepos(t) | |
| 147 | - | started := make(chan struct{}) | |
| 148 | - | var cancelled atomic.Bool | |
| 149 | - | git.EXPECT().Maintain(mockAny, n).RunAndReturn(func(ctx context.Context, _ domain.RepoName) error { | |
| 150 | - | close(started) | |
| 151 | - | <-ctx.Done() | |
| 152 | - | cancelled.Store(true) | |
| 153 | - | return ctx.Err() | |
| 154 | - | }) | |
| 155 | - | m := app.NewMaintainer(ctx, git, time.Millisecond) | |
| 156 | - | m.Schedule(ctx, n) | |
| 157 | - | <-started | |
| 158 | - | done := make(chan struct{}) | |
| 159 | - | go func() { m.Close(ctx); close(done) }() | |
| 160 | - | select { | |
| 161 | - | case <-done: | |
| 162 | - | case <-time.After(5 * time.Second): | |
| 163 | - | t.Fatal("Close did not return") | |
| 164 | - | } | |
| 165 | - | assert.True(t, cancelled.Load()) | |
| 166 | - | }) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | func TestMaintainerConcurrentSchedule(t *testing.T) { | |
| 170 | - | ctx := context.Background() | |
| 171 | - | git := mocks.NewGitRepos(t) | |
| 172 | - | git.EXPECT().Maintain(mockAny, mockAny).Return(nil).Maybe() | |
| 173 | - | m := app.NewMaintainer(ctx, git, time.Millisecond) | |
| 174 | - | var wg sync.WaitGroup | |
| 175 | - | for i := range 8 { | |
| 176 | - | wg.Add(1) | |
| 177 | - | go func() { | |
| 178 | - | defer wg.Done() | |
| 179 | - | for range 50 { | |
| 180 | - | m.Schedule(ctx, name(t, string(rune('a'+i%3)))) | |
| 181 | - | runtime.Gosched() | |
| 182 | - | } | |
| 183 | - | }() | |
| 184 | - | } | |
| 185 | - | wg.Wait() | |
| 186 | - | m.Close(ctx) | |
| 187 | - | } | |
R cmd/ermite/mr_api_test.go → internal/app/mr_api_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "os" | |
R cmd/ermite/root.go → internal/app/root.go +2 -2 renamed, 93% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "github.com/spf13/cobra" | |
| @@ -21,7 +21,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 21 | 21 | "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" + | |
| 22 | 22 | "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.", | |
| 23 | 23 | SilenceUsage: true, | |
| 24 | - | SilenceErrors: true, // main prints the error once | |
| 24 | + | SilenceErrors: true, // cmd/ermite prints the error once | |
| 25 | 25 | } | |
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
R cmd/ermite/serve.go → internal/app/serve.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
R cmd/ermite/testdata/web/blob-binary.html → internal/app/testdata/web/blob-binary.html +28 -73 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -230,7 +231,7 @@ | |||
| 230 | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 231 | 232 | <div class="panel-head"> | |
| 232 | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 234 | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 235 | 236 | <kbd>esc</kbd> | |
| 236 | 237 | </button> | |
| @@ -265,13 +266,7 @@ | |||
| 265 | 266 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 266 | 267 | <div class="panel-head"> | |
| 267 | 268 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 269 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 275 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 276 | 271 | <kbd>esc</kbd> | |
| 277 | 272 | </button> | |
| @@ -342,67 +337,27 @@ | |||
| 342 | 337 | <kbd>esc</kbd> close</span> | |
| 343 | 338 | </p> | |
| 344 | 339 | </dialog> | |
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 346 | - | if (q('dialog[open]').count) return; | |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 354 | - | })(1); | |
| 355 | - | k-k from:body -> (async (d) => { | |
| 356 | - | if (q('dialog[open]').count) return; | |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 364 | - | })(-1); | |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 383 | - | k-next from:body -> { | |
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 387 | - | }; | |
| 388 | - | k-split from:body -> { | |
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 392 | - | }; | |
| 393 | - | k-unified from:body -> { | |
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 397 | - | }; | |
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 399 | - | k-permalink from:body -> { | |
| 400 | - | let b = document.body.dataset; | |
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 403 | - | navigator.clipboard?.writeText(location.href); | |
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 405 | - | }"> | |
| 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 341 | + | k-k from:body -> ermite.move(-1); | |
| 342 | + | k-top from:body -> ermite.edge(false); | |
| 343 | + | k-bottom from:body -> ermite.edge(true); | |
| 344 | + | k-open from:body -> ermite.open(); | |
| 345 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 346 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 349 | + | k-filter from:body -> ermite.focusFilter(); | |
| 350 | + | k-gh from:body -> ermite.go('.brand'); | |
| 351 | + | k-s from:body -> ermite.tab('s'); | |
| 352 | + | k-t from:body -> ermite.tab('t'); | |
| 353 | + | k-c from:body -> ermite.tab('c'); | |
| 354 | + | k-r from:body -> ermite.tab('r'); | |
| 355 | + | k-m from:body -> ermite.tab('m'); | |
| 356 | + | k-next from:body -> ermite.nextNote(); | |
| 357 | + | k-split from:body -> ermite.layout('split'); | |
| 358 | + | k-unified from:body -> ermite.layout('unified'); | |
| 359 | + | k-collapse from:body -> ermite.collapse(); | |
| 360 | + | k-permalink from:body -> ermite.permalink()"> | |
| 406 | 361 | </div> | |
| 407 | 362 | </body> | |
| 408 | 363 | </html> | |
R cmd/ermite/testdata/web/blob-code.html → internal/app/testdata/web/blob-code.html +29 -74 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,7 +103,7 @@ | |||
| 102 | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 103 | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 104 | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 105 | - | <code> | |
| 106 | + | <code hx-ignore> | |
| 106 | 107 | <span class="line" id="L1"> | |
| 107 | 108 | <a class="ln" href="#L1">1</a> | |
| 108 | 109 | <span class="kn">package</span> | |
| @@ -300,7 +301,7 @@ | |||
| 300 | 301 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 301 | 302 | <div class="panel-head"> | |
| 302 | 303 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 303 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 304 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 304 | 305 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 305 | 306 | <kbd>esc</kbd> | |
| 306 | 307 | </button> | |
| @@ -335,13 +336,7 @@ | |||
| 335 | 336 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 336 | 337 | <div class="panel-head"> | |
| 337 | 338 | <label for="cmd-q" class="sr">go to</label> | |
| 338 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 339 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 340 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 341 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 342 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 343 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 344 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 339 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 345 | 340 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 346 | 341 | <kbd>esc</kbd> | |
| 347 | 342 | </button> | |
| @@ -412,67 +407,27 @@ | |||
| 412 | 407 | <kbd>esc</kbd> close</span> | |
| 413 | 408 | </p> | |
| 414 | 409 | </dialog> | |
| 415 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 416 | - | if (q('dialog[open]').count) return; | |
| 417 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 418 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 419 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 420 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 421 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 422 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 423 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 424 | - | })(1); | |
| 425 | - | k-k from:body -> (async (d) => { | |
| 426 | - | if (q('dialog[open]').count) return; | |
| 427 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 428 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 429 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 430 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 431 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 432 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 433 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 434 | - | })(-1); | |
| 435 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 436 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 437 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 438 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 439 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 440 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 441 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 442 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 443 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 444 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 445 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 446 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 447 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 448 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 449 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 450 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 451 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 452 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 453 | - | k-next from:body -> { | |
| 454 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 455 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 456 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 457 | - | }; | |
| 458 | - | k-split from:body -> { | |
| 459 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 460 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 461 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 462 | - | }; | |
| 463 | - | k-unified from:body -> { | |
| 464 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 465 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 466 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 467 | - | }; | |
| 468 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 469 | - | k-permalink from:body -> { | |
| 470 | - | let b = document.body.dataset; | |
| 471 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 472 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 473 | - | navigator.clipboard?.writeText(location.href); | |
| 474 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 475 | - | }"> | |
| 410 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 411 | + | k-k from:body -> ermite.move(-1); | |
| 412 | + | k-top from:body -> ermite.edge(false); | |
| 413 | + | k-bottom from:body -> ermite.edge(true); | |
| 414 | + | k-open from:body -> ermite.open(); | |
| 415 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 416 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 417 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 418 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 419 | + | k-filter from:body -> ermite.focusFilter(); | |
| 420 | + | k-gh from:body -> ermite.go('.brand'); | |
| 421 | + | k-s from:body -> ermite.tab('s'); | |
| 422 | + | k-t from:body -> ermite.tab('t'); | |
| 423 | + | k-c from:body -> ermite.tab('c'); | |
| 424 | + | k-r from:body -> ermite.tab('r'); | |
| 425 | + | k-m from:body -> ermite.tab('m'); | |
| 426 | + | k-next from:body -> ermite.nextNote(); | |
| 427 | + | k-split from:body -> ermite.layout('split'); | |
| 428 | + | k-unified from:body -> ermite.layout('unified'); | |
| 429 | + | k-collapse from:body -> ermite.collapse(); | |
| 430 | + | k-permalink from:body -> ermite.permalink()"> | |
| 476 | 431 | </div> | |
| 477 | 432 | </body> | |
| 478 | 433 | </html> | |
R cmd/ermite/testdata/web/blob-empty.html → internal/app/testdata/web/blob-empty.html +28 -73 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -230,7 +231,7 @@ | |||
| 230 | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 231 | 232 | <div class="panel-head"> | |
| 232 | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 234 | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 235 | 236 | <kbd>esc</kbd> | |
| 236 | 237 | </button> | |
| @@ -265,13 +266,7 @@ | |||
| 265 | 266 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 266 | 267 | <div class="panel-head"> | |
| 267 | 268 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 269 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 275 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 276 | 271 | <kbd>esc</kbd> | |
| 277 | 272 | </button> | |
| @@ -342,67 +337,27 @@ | |||
| 342 | 337 | <kbd>esc</kbd> close</span> | |
| 343 | 338 | </p> | |
| 344 | 339 | </dialog> | |
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 346 | - | if (q('dialog[open]').count) return; | |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 354 | - | })(1); | |
| 355 | - | k-k from:body -> (async (d) => { | |
| 356 | - | if (q('dialog[open]').count) return; | |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 364 | - | })(-1); | |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 383 | - | k-next from:body -> { | |
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 387 | - | }; | |
| 388 | - | k-split from:body -> { | |
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 392 | - | }; | |
| 393 | - | k-unified from:body -> { | |
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 397 | - | }; | |
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 399 | - | k-permalink from:body -> { | |
| 400 | - | let b = document.body.dataset; | |
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 403 | - | navigator.clipboard?.writeText(location.href); | |
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 405 | - | }"> | |
| 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 341 | + | k-k from:body -> ermite.move(-1); | |
| 342 | + | k-top from:body -> ermite.edge(false); | |
| 343 | + | k-bottom from:body -> ermite.edge(true); | |
| 344 | + | k-open from:body -> ermite.open(); | |
| 345 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 346 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 349 | + | k-filter from:body -> ermite.focusFilter(); | |
| 350 | + | k-gh from:body -> ermite.go('.brand'); | |
| 351 | + | k-s from:body -> ermite.tab('s'); | |
| 352 | + | k-t from:body -> ermite.tab('t'); | |
| 353 | + | k-c from:body -> ermite.tab('c'); | |
| 354 | + | k-r from:body -> ermite.tab('r'); | |
| 355 | + | k-m from:body -> ermite.tab('m'); | |
| 356 | + | k-next from:body -> ermite.nextNote(); | |
| 357 | + | k-split from:body -> ermite.layout('split'); | |
| 358 | + | k-unified from:body -> ermite.layout('unified'); | |
| 359 | + | k-collapse from:body -> ermite.collapse(); | |
| 360 | + | k-permalink from:body -> ermite.permalink()"> | |
| 406 | 361 | </div> | |
| 407 | 362 | </body> | |
| 408 | 363 | </html> | |
R cmd/ermite/testdata/web/blob-image.html → internal/app/testdata/web/blob-image.html +28 -73 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -232,7 +233,7 @@ | |||
| 232 | 233 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 233 | 234 | <div class="panel-head"> | |
| 234 | 235 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 235 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 236 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 236 | 237 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 237 | 238 | <kbd>esc</kbd> | |
| 238 | 239 | </button> | |
| @@ -267,13 +268,7 @@ | |||
| 267 | 268 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 268 | 269 | <div class="panel-head"> | |
| 269 | 270 | <label for="cmd-q" class="sr">go to</label> | |
| 270 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 271 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 272 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 273 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 274 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 275 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 276 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 271 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 277 | 272 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 278 | 273 | <kbd>esc</kbd> | |
| 279 | 274 | </button> | |
| @@ -344,67 +339,27 @@ | |||
| 344 | 339 | <kbd>esc</kbd> close</span> | |
| 345 | 340 | </p> | |
| 346 | 341 | </dialog> | |
| 347 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 348 | - | if (q('dialog[open]').count) return; | |
| 349 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 350 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 351 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 352 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 353 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 354 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 355 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 356 | - | })(1); | |
| 357 | - | k-k from:body -> (async (d) => { | |
| 358 | - | if (q('dialog[open]').count) return; | |
| 359 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 360 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 361 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 362 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 363 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 364 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 365 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 366 | - | })(-1); | |
| 367 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 368 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 369 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 370 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 371 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 372 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 373 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 374 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 375 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 376 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 377 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 378 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 379 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 380 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 381 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 382 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 383 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 384 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 385 | - | k-next from:body -> { | |
| 386 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 387 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 388 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 389 | - | }; | |
| 390 | - | k-split from:body -> { | |
| 391 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 392 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 393 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 394 | - | }; | |
| 395 | - | k-unified from:body -> { | |
| 396 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 397 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 398 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 399 | - | }; | |
| 400 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 401 | - | k-permalink from:body -> { | |
| 402 | - | let b = document.body.dataset; | |
| 403 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 404 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 405 | - | navigator.clipboard?.writeText(location.href); | |
| 406 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 407 | - | }"> | |
| 342 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 343 | + | k-k from:body -> ermite.move(-1); | |
| 344 | + | k-top from:body -> ermite.edge(false); | |
| 345 | + | k-bottom from:body -> ermite.edge(true); | |
| 346 | + | k-open from:body -> ermite.open(); | |
| 347 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 348 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 349 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 350 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 351 | + | k-filter from:body -> ermite.focusFilter(); | |
| 352 | + | k-gh from:body -> ermite.go('.brand'); | |
| 353 | + | k-s from:body -> ermite.tab('s'); | |
| 354 | + | k-t from:body -> ermite.tab('t'); | |
| 355 | + | k-c from:body -> ermite.tab('c'); | |
| 356 | + | k-r from:body -> ermite.tab('r'); | |
| 357 | + | k-m from:body -> ermite.tab('m'); | |
| 358 | + | k-next from:body -> ermite.nextNote(); | |
| 359 | + | k-split from:body -> ermite.layout('split'); | |
| 360 | + | k-unified from:body -> ermite.layout('unified'); | |
| 361 | + | k-collapse from:body -> ermite.collapse(); | |
| 362 | + | k-permalink from:body -> ermite.permalink()"> | |
| 408 | 363 | </div> | |
| 409 | 364 | </body> | |
| 410 | 365 | </html> | |
R cmd/ermite/testdata/web/blob-markdown.html → internal/app/testdata/web/blob-markdown.html +30 -75 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -48,19 +49,19 @@ | |||
| 48 | 49 | </ol> | |
| 49 | 50 | </nav> | |
| 50 | 51 | <div class="hdr-acts"> | |
| 51 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 52 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 52 | 53 | <span class="ico i-branch" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="ref-name">main</span> | |
| 55 | 56 | <span class="sha">SHA7</span> | |
| 56 | - | </button> | |
| 57 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 57 | + | </a> | |
| 58 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 58 | 59 | <span class="ico i-search" aria-hidden="true"> | |
| 59 | 60 | </span> | |
| 60 | 61 | <span class="search-label">Go to…</span> | |
| 61 | 62 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 62 | 63 | </button> | |
| 63 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 64 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 64 | 65 | </div> | |
| 65 | 66 | </div> | |
| 66 | 67 | <nav class="tabs" aria-label="Sections"> | |
| @@ -98,7 +99,7 @@ | |||
| 98 | 99 | <a class="btn" href="/files/tree/main/docs">Folder</a> | |
| 99 | 100 | </span> | |
| 100 | 101 | </h1> | |
| 101 | - | <article class="prose" aria-label="guide.md"> | |
| 102 | + | <article class="prose" aria-label="guide.md" hx-ignore> | |
| 102 | 103 | <h1 id="md-guide">Guide</h1> | |
| 103 | 104 | <h2 id="md-steps">Steps</h2> | |
| 104 | 105 | <p>Run it.</p> | |
| @@ -116,7 +117,7 @@ | |||
| 116 | 117 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 117 | 118 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 118 | 119 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 119 | - | <code> | |
| 120 | + | <code hx-ignore> | |
| 120 | 121 | <span class="line" id="L1"> | |
| 121 | 122 | <a class="ln" href="#L1">1</a> | |
| 122 | 123 | <span class="gh"># Guide</span> | |
| @@ -297,7 +298,7 @@ | |||
| 297 | 298 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 298 | 299 | <div class="panel-head"> | |
| 299 | 300 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 301 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 301 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 302 | 303 | <kbd>esc</kbd> | |
| 303 | 304 | </button> | |
| @@ -332,13 +333,7 @@ | |||
| 332 | 333 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 333 | 334 | <div class="panel-head"> | |
| 334 | 335 | <label for="cmd-q" class="sr">go to</label> | |
| 335 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 336 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 337 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 338 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 339 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 340 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 341 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 336 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 342 | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 343 | 338 | <kbd>esc</kbd> | |
| 344 | 339 | </button> | |
| @@ -409,67 +404,27 @@ | |||
| 409 | 404 | <kbd>esc</kbd> close</span> | |
| 410 | 405 | </p> | |
| 411 | 406 | </dialog> | |
| 412 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 413 | - | if (q('dialog[open]').count) return; | |
| 414 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 415 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 416 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 417 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 418 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 419 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 420 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 421 | - | })(1); | |
| 422 | - | k-k from:body -> (async (d) => { | |
| 423 | - | if (q('dialog[open]').count) return; | |
| 424 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 425 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 426 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 427 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 428 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 429 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 430 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 431 | - | })(-1); | |
| 432 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 433 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 434 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 435 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 436 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 437 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 438 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 439 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 440 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 441 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 442 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 443 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 444 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 445 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 446 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 447 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 448 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 449 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 450 | - | k-next from:body -> { | |
| 451 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 452 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 453 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 454 | - | }; | |
| 455 | - | k-split from:body -> { | |
| 456 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 457 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 458 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 459 | - | }; | |
| 460 | - | k-unified from:body -> { | |
| 461 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 462 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 463 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 464 | - | }; | |
| 465 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 466 | - | k-permalink from:body -> { | |
| 467 | - | let b = document.body.dataset; | |
| 468 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 469 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 470 | - | navigator.clipboard?.writeText(location.href); | |
| 471 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 472 | - | }"> | |
| 407 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 408 | + | k-k from:body -> ermite.move(-1); | |
| 409 | + | k-top from:body -> ermite.edge(false); | |
| 410 | + | k-bottom from:body -> ermite.edge(true); | |
| 411 | + | k-open from:body -> ermite.open(); | |
| 412 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 413 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 414 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 415 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 416 | + | k-filter from:body -> ermite.focusFilter(); | |
| 417 | + | k-gh from:body -> ermite.go('.brand'); | |
| 418 | + | k-s from:body -> ermite.tab('s'); | |
| 419 | + | k-t from:body -> ermite.tab('t'); | |
| 420 | + | k-c from:body -> ermite.tab('c'); | |
| 421 | + | k-r from:body -> ermite.tab('r'); | |
| 422 | + | k-m from:body -> ermite.tab('m'); | |
| 423 | + | k-next from:body -> ermite.nextNote(); | |
| 424 | + | k-split from:body -> ermite.layout('split'); | |
| 425 | + | k-unified from:body -> ermite.layout('unified'); | |
| 426 | + | k-collapse from:body -> ermite.collapse(); | |
| 427 | + | k-permalink from:body -> ermite.permalink()"> | |
| 473 | 428 | </div> | |
| 474 | 429 | </body> | |
| 475 | 430 | </html> | |
R cmd/ermite/testdata/web/blob-svg.html → internal/app/testdata/web/blob-svg.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,7 +103,7 @@ | |||
| 102 | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 103 | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 104 | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 105 | - | <code> | |
| 106 | + | <code hx-ignore> | |
| 106 | 107 | <span class="line" id="L1"> | |
| 107 | 108 | <a class="ln" href="#L1">1</a> | |
| 108 | 109 | <span class="nt"><svg</span> | |
| @@ -255,7 +256,7 @@ | |||
| 255 | 256 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 256 | 257 | <div class="panel-head"> | |
| 257 | 258 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 258 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 259 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 259 | 260 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 260 | 261 | <kbd>esc</kbd> | |
| 261 | 262 | </button> | |
| @@ -290,13 +291,7 @@ | |||
| 290 | 291 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 291 | 292 | <div class="panel-head"> | |
| 292 | 293 | <label for="cmd-q" class="sr">go to</label> | |
| 293 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 294 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 295 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 296 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 297 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 298 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 299 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 294 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 300 | 295 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 301 | 296 | <kbd>esc</kbd> | |
| 302 | 297 | </button> | |
| @@ -367,67 +362,27 @@ | |||
| 367 | 362 | <kbd>esc</kbd> close</span> | |
| 368 | 363 | </p> | |
| 369 | 364 | </dialog> | |
| 370 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 371 | - | if (q('dialog[open]').count) return; | |
| 372 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 373 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 374 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 375 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 376 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 377 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 378 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 379 | - | })(1); | |
| 380 | - | k-k from:body -> (async (d) => { | |
| 381 | - | if (q('dialog[open]').count) return; | |
| 382 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 383 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 384 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 385 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 386 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 387 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 388 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 389 | - | })(-1); | |
| 390 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 391 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 392 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 393 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 394 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 395 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 396 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 397 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 398 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 399 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 400 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 401 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 402 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 403 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 404 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 405 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 406 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 407 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 408 | - | k-next from:body -> { | |
| 409 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 410 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 411 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 412 | - | }; | |
| 413 | - | k-split from:body -> { | |
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 415 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 417 | - | }; | |
| 418 | - | k-unified from:body -> { | |
| 419 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 420 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 421 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 422 | - | }; | |
| 423 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 424 | - | k-permalink from:body -> { | |
| 425 | - | let b = document.body.dataset; | |
| 426 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 427 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 428 | - | navigator.clipboard?.writeText(location.href); | |
| 429 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 430 | - | }"> | |
| 365 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 366 | + | k-k from:body -> ermite.move(-1); | |
| 367 | + | k-top from:body -> ermite.edge(false); | |
| 368 | + | k-bottom from:body -> ermite.edge(true); | |
| 369 | + | k-open from:body -> ermite.open(); | |
| 370 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 371 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 372 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 373 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 374 | + | k-filter from:body -> ermite.focusFilter(); | |
| 375 | + | k-gh from:body -> ermite.go('.brand'); | |
| 376 | + | k-s from:body -> ermite.tab('s'); | |
| 377 | + | k-t from:body -> ermite.tab('t'); | |
| 378 | + | k-c from:body -> ermite.tab('c'); | |
| 379 | + | k-r from:body -> ermite.tab('r'); | |
| 380 | + | k-m from:body -> ermite.tab('m'); | |
| 381 | + | k-next from:body -> ermite.nextNote(); | |
| 382 | + | k-split from:body -> ermite.layout('split'); | |
| 383 | + | k-unified from:body -> ermite.layout('unified'); | |
| 384 | + | k-collapse from:body -> ermite.collapse(); | |
| 385 | + | k-permalink from:body -> ermite.permalink()"> | |
| 431 | 386 | </div> | |
| 432 | 387 | </body> | |
| 433 | 388 | </html> | |
R cmd/ermite/testdata/web/blob-text.html → internal/app/testdata/web/blob-text.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,7 +103,7 @@ | |||
| 102 | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 103 | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 104 | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 105 | - | <code> | |
| 106 | + | <code hx-ignore> | |
| 106 | 107 | <span class="line" id="L1"> | |
| 107 | 108 | <a class="ln" href="#L1">1</a>plain notes</span> | |
| 108 | 109 | <span class="line" id="L2"> | |
| @@ -250,7 +251,7 @@ | |||
| 250 | 251 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 251 | 252 | <div class="panel-head"> | |
| 252 | 253 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 253 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 254 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 254 | 255 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 255 | 256 | <kbd>esc</kbd> | |
| 256 | 257 | </button> | |
| @@ -285,13 +286,7 @@ | |||
| 285 | 286 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 286 | 287 | <div class="panel-head"> | |
| 287 | 288 | <label for="cmd-q" class="sr">go to</label> | |
| 288 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 289 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 290 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 291 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 292 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 293 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 294 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 289 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 295 | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 296 | 291 | <kbd>esc</kbd> | |
| 297 | 292 | </button> | |
| @@ -362,67 +357,27 @@ | |||
| 362 | 357 | <kbd>esc</kbd> close</span> | |
| 363 | 358 | </p> | |
| 364 | 359 | </dialog> | |
| 365 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 366 | - | if (q('dialog[open]').count) return; | |
| 367 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 368 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 369 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 370 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 371 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 372 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 373 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 374 | - | })(1); | |
| 375 | - | k-k from:body -> (async (d) => { | |
| 376 | - | if (q('dialog[open]').count) return; | |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 384 | - | })(-1); | |
| 385 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 386 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 387 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 388 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 389 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 390 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 391 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 392 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 393 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 394 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 395 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 396 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 397 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 398 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 399 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 400 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 401 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 402 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 403 | - | k-next from:body -> { | |
| 404 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 405 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 406 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 407 | - | }; | |
| 408 | - | k-split from:body -> { | |
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 410 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 412 | - | }; | |
| 413 | - | k-unified from:body -> { | |
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 415 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 417 | - | }; | |
| 418 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 419 | - | k-permalink from:body -> { | |
| 420 | - | let b = document.body.dataset; | |
| 421 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 422 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 423 | - | navigator.clipboard?.writeText(location.href); | |
| 424 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 425 | - | }"> | |
| 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 361 | + | k-k from:body -> ermite.move(-1); | |
| 362 | + | k-top from:body -> ermite.edge(false); | |
| 363 | + | k-bottom from:body -> ermite.edge(true); | |
| 364 | + | k-open from:body -> ermite.open(); | |
| 365 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 366 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 367 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 368 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 369 | + | k-filter from:body -> ermite.focusFilter(); | |
| 370 | + | k-gh from:body -> ermite.go('.brand'); | |
| 371 | + | k-s from:body -> ermite.tab('s'); | |
| 372 | + | k-t from:body -> ermite.tab('t'); | |
| 373 | + | k-c from:body -> ermite.tab('c'); | |
| 374 | + | k-r from:body -> ermite.tab('r'); | |
| 375 | + | k-m from:body -> ermite.tab('m'); | |
| 376 | + | k-next from:body -> ermite.nextNote(); | |
| 377 | + | k-split from:body -> ermite.layout('split'); | |
| 378 | + | k-unified from:body -> ermite.layout('unified'); | |
| 379 | + | k-collapse from:body -> ermite.collapse(); | |
| 380 | + | k-permalink from:body -> ermite.permalink()"> | |
| 426 | 381 | </div> | |
| 427 | 382 | </body> | |
| 428 | 383 | </html> | |
R cmd/ermite/testdata/web/blob-too-large.html → internal/app/testdata/web/blob-too-large.html +28 -73 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -230,7 +231,7 @@ | |||
| 230 | 231 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 231 | 232 | <div class="panel-head"> | |
| 232 | 233 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 233 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 234 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 234 | 235 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 235 | 236 | <kbd>esc</kbd> | |
| 236 | 237 | </button> | |
| @@ -265,13 +266,7 @@ | |||
| 265 | 266 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 266 | 267 | <div class="panel-head"> | |
| 267 | 268 | <label for="cmd-q" class="sr">go to</label> | |
| 268 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 269 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 270 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 271 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 272 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 273 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 274 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 269 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 275 | 270 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 276 | 271 | <kbd>esc</kbd> | |
| 277 | 272 | </button> | |
| @@ -342,67 +337,27 @@ | |||
| 342 | 337 | <kbd>esc</kbd> close</span> | |
| 343 | 338 | </p> | |
| 344 | 339 | </dialog> | |
| 345 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 346 | - | if (q('dialog[open]').count) return; | |
| 347 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 348 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 349 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 350 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 351 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 352 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 353 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 354 | - | })(1); | |
| 355 | - | k-k from:body -> (async (d) => { | |
| 356 | - | if (q('dialog[open]').count) return; | |
| 357 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 358 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 359 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 360 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 361 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 362 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 363 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 364 | - | })(-1); | |
| 365 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 366 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 367 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 368 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 369 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 370 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 371 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 372 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 373 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 374 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 375 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 376 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 377 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 378 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 379 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 380 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 381 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 382 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 383 | - | k-next from:body -> { | |
| 384 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 385 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 386 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 387 | - | }; | |
| 388 | - | k-split from:body -> { | |
| 389 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 390 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 391 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 392 | - | }; | |
| 393 | - | k-unified from:body -> { | |
| 394 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 395 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 396 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 397 | - | }; | |
| 398 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 399 | - | k-permalink from:body -> { | |
| 400 | - | let b = document.body.dataset; | |
| 401 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 402 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 403 | - | navigator.clipboard?.writeText(location.href); | |
| 404 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 405 | - | }"> | |
| 340 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 341 | + | k-k from:body -> ermite.move(-1); | |
| 342 | + | k-top from:body -> ermite.edge(false); | |
| 343 | + | k-bottom from:body -> ermite.edge(true); | |
| 344 | + | k-open from:body -> ermite.open(); | |
| 345 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 346 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 347 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 348 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 349 | + | k-filter from:body -> ermite.focusFilter(); | |
| 350 | + | k-gh from:body -> ermite.go('.brand'); | |
| 351 | + | k-s from:body -> ermite.tab('s'); | |
| 352 | + | k-t from:body -> ermite.tab('t'); | |
| 353 | + | k-c from:body -> ermite.tab('c'); | |
| 354 | + | k-r from:body -> ermite.tab('r'); | |
| 355 | + | k-m from:body -> ermite.tab('m'); | |
| 356 | + | k-next from:body -> ermite.nextNote(); | |
| 357 | + | k-split from:body -> ermite.layout('split'); | |
| 358 | + | k-unified from:body -> ermite.layout('unified'); | |
| 359 | + | k-collapse from:body -> ermite.collapse(); | |
| 360 | + | k-permalink from:body -> ermite.permalink()"> | |
| 406 | 361 | </div> | |
| 407 | 362 | </body> | |
| 408 | 363 | </html> | |
R cmd/ermite/testdata/web/blob-trojan.html → internal/app/testdata/web/blob-trojan.html +29 -74 renamed, 63% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,7 +103,7 @@ | |||
| 102 | 103 | q('.line in this').arr().forEach(l => { let k = +l.id.slice(1); l.classList.toggle('sel', k >= lo && k <= hi) }); | |
| 103 | 104 | document.body.dataset.sel = lo ? 'L' + lo + (hi > lo ? '-L' + hi + ' · ' + (hi - lo + 1) + ' lines' : '') : ''; | |
| 104 | 105 | if (lo && event.detail?.scroll) document.getElementById('L' + lo)?.scrollIntoView({ block: 'center' })" hx-live="if (!data.ready) { data.ready = '1'; trigger('sel', { scroll: true }) }" data-ready=""> | |
| 105 | - | <code> | |
| 106 | + | <code hx-ignore> | |
| 106 | 107 | <span class="line" id="L1"> | |
| 107 | 108 | <a class="ln" href="#L1">1</a> | |
| 108 | 109 | <span class="kn">package</span> | |
| @@ -297,7 +298,7 @@ | |||
| 297 | 298 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 298 | 299 | <div class="panel-head"> | |
| 299 | 300 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 301 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 301 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 302 | 303 | <kbd>esc</kbd> | |
| 303 | 304 | </button> | |
| @@ -332,13 +333,7 @@ | |||
| 332 | 333 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 333 | 334 | <div class="panel-head"> | |
| 334 | 335 | <label for="cmd-q" class="sr">go to</label> | |
| 335 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 336 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 337 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 338 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 339 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 340 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 341 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 336 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 342 | 337 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 343 | 338 | <kbd>esc</kbd> | |
| 344 | 339 | </button> | |
| @@ -409,67 +404,27 @@ | |||
| 409 | 404 | <kbd>esc</kbd> close</span> | |
| 410 | 405 | </p> | |
| 411 | 406 | </dialog> | |
| 412 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 413 | - | if (q('dialog[open]').count) return; | |
| 414 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 415 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 416 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 417 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 418 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 419 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 420 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 421 | - | })(1); | |
| 422 | - | k-k from:body -> (async (d) => { | |
| 423 | - | if (q('dialog[open]').count) return; | |
| 424 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 425 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 426 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 427 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 428 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 429 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 430 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 431 | - | })(-1); | |
| 432 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 433 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 434 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 435 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 436 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 437 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 438 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 439 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 440 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 441 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 442 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 443 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 444 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 445 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 446 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 447 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 448 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 449 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 450 | - | k-next from:body -> { | |
| 451 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 452 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 453 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 454 | - | }; | |
| 455 | - | k-split from:body -> { | |
| 456 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 457 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 458 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 459 | - | }; | |
| 460 | - | k-unified from:body -> { | |
| 461 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 462 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 463 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 464 | - | }; | |
| 465 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 466 | - | k-permalink from:body -> { | |
| 467 | - | let b = document.body.dataset; | |
| 468 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 469 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 470 | - | navigator.clipboard?.writeText(location.href); | |
| 471 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 472 | - | }"> | |
| 407 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 408 | + | k-k from:body -> ermite.move(-1); | |
| 409 | + | k-top from:body -> ermite.edge(false); | |
| 410 | + | k-bottom from:body -> ermite.edge(true); | |
| 411 | + | k-open from:body -> ermite.open(); | |
| 412 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 413 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 414 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 415 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 416 | + | k-filter from:body -> ermite.focusFilter(); | |
| 417 | + | k-gh from:body -> ermite.go('.brand'); | |
| 418 | + | k-s from:body -> ermite.tab('s'); | |
| 419 | + | k-t from:body -> ermite.tab('t'); | |
| 420 | + | k-c from:body -> ermite.tab('c'); | |
| 421 | + | k-r from:body -> ermite.tab('r'); | |
| 422 | + | k-m from:body -> ermite.tab('m'); | |
| 423 | + | k-next from:body -> ermite.nextNote(); | |
| 424 | + | k-split from:body -> ermite.layout('split'); | |
| 425 | + | k-unified from:body -> ermite.layout('unified'); | |
| 426 | + | k-collapse from:body -> ermite.collapse(); | |
| 427 | + | k-permalink from:body -> ermite.permalink()"> | |
| 473 | 428 | </div> | |
| 474 | 429 | </body> | |
| 475 | 430 | </html> | |
R cmd/ermite/testdata/web/commit-big.html → internal/app/testdata/web/commit-big.html +32 -77 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -107,11 +108,11 @@ | |||
| 107 | 108 | </span> | |
| 108 | 109 | <span class="grow"> | |
| 109 | 110 | </span> | |
| 110 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 111 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 111 | 112 | </a> | |
| 112 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 113 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 113 | 114 | </a> | |
| 114 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 115 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 115 | 116 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 116 | 117 | <kbd aria-hidden="true">x</kbd> | |
| 117 | 118 | </button> | |
| @@ -136,7 +137,7 @@ | |||
| 136 | 137 | </h2> | |
| 137 | 138 | <ol class="files" aria-label="Changed files"> | |
| 138 | 139 | <li> | |
| 139 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 140 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 140 | 141 | <span class="st" title="A">A</span> | |
| 141 | 142 | <span class="p" title="big.txt">big.txt</span> | |
| 142 | 143 | <span class="n"> | |
| @@ -267,7 +268,7 @@ | |||
| 267 | 268 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 268 | 269 | <div class="panel-head"> | |
| 269 | 270 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 270 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 271 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 271 | 272 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 272 | 273 | <kbd>esc</kbd> | |
| 273 | 274 | </button> | |
| @@ -302,13 +303,7 @@ | |||
| 302 | 303 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 303 | 304 | <div class="panel-head"> | |
| 304 | 305 | <label for="cmd-q" class="sr">go to</label> | |
| 305 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 306 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 307 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 308 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 309 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 310 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 311 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 306 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 312 | 307 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 313 | 308 | <kbd>esc</kbd> | |
| 314 | 309 | </button> | |
| @@ -379,67 +374,27 @@ | |||
| 379 | 374 | <kbd>esc</kbd> close</span> | |
| 380 | 375 | </p> | |
| 381 | 376 | </dialog> | |
| 382 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 383 | - | if (q('dialog[open]').count) return; | |
| 384 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 385 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 386 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 387 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 388 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 389 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 390 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 391 | - | })(1); | |
| 392 | - | k-k from:body -> (async (d) => { | |
| 393 | - | if (q('dialog[open]').count) return; | |
| 394 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 395 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 396 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 397 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 398 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 399 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 400 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 401 | - | })(-1); | |
| 402 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 403 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 404 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 405 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 406 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 407 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 408 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 409 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 410 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 411 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 412 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 413 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 414 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 415 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 416 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 417 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 418 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 419 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 420 | - | k-next from:body -> { | |
| 421 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 422 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 423 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 424 | - | }; | |
| 425 | - | k-split from:body -> { | |
| 426 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 427 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 428 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 429 | - | }; | |
| 430 | - | k-unified from:body -> { | |
| 431 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 432 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 433 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 434 | - | }; | |
| 435 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 436 | - | k-permalink from:body -> { | |
| 437 | - | let b = document.body.dataset; | |
| 438 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 439 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 440 | - | navigator.clipboard?.writeText(location.href); | |
| 441 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 442 | - | }"> | |
| 377 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 378 | + | k-k from:body -> ermite.move(-1); | |
| 379 | + | k-top from:body -> ermite.edge(false); | |
| 380 | + | k-bottom from:body -> ermite.edge(true); | |
| 381 | + | k-open from:body -> ermite.open(); | |
| 382 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 383 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 384 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 385 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 386 | + | k-filter from:body -> ermite.focusFilter(); | |
| 387 | + | k-gh from:body -> ermite.go('.brand'); | |
| 388 | + | k-s from:body -> ermite.tab('s'); | |
| 389 | + | k-t from:body -> ermite.tab('t'); | |
| 390 | + | k-c from:body -> ermite.tab('c'); | |
| 391 | + | k-r from:body -> ermite.tab('r'); | |
| 392 | + | k-m from:body -> ermite.tab('m'); | |
| 393 | + | k-next from:body -> ermite.nextNote(); | |
| 394 | + | k-split from:body -> ermite.layout('split'); | |
| 395 | + | k-unified from:body -> ermite.layout('unified'); | |
| 396 | + | k-collapse from:body -> ermite.collapse(); | |
| 397 | + | k-permalink from:body -> ermite.permalink()"> | |
| 443 | 398 | </div> | |
| 444 | 399 | </body> | |
| 445 | 400 | </html> | |
R cmd/ermite/testdata/web/commit-binary.html → internal/app/testdata/web/commit-binary.html +32 -77 renamed, 55% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -103,11 +104,11 @@ | |||
| 103 | 104 | </span> | |
| 104 | 105 | <span class="grow"> | |
| 105 | 106 | </span> | |
| 106 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 107 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 107 | 108 | </a> | |
| 108 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 109 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 109 | 110 | </a> | |
| 110 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 111 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 111 | 112 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 112 | 113 | <kbd aria-hidden="true">x</kbd> | |
| 113 | 114 | </button> | |
| @@ -131,7 +132,7 @@ | |||
| 131 | 132 | </h2> | |
| 132 | 133 | <ol class="files" aria-label="Changed files"> | |
| 133 | 134 | <li> | |
| 134 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 135 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 135 | 136 | <span class="st" title="M">M</span> | |
| 136 | 137 | <span class="p" title="logo.bin">logo.bin</span> | |
| 137 | 138 | <span class="n"> | |
| @@ -262,7 +263,7 @@ | |||
| 262 | 263 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 263 | 264 | <div class="panel-head"> | |
| 264 | 265 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 265 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 266 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 266 | 267 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 267 | 268 | <kbd>esc</kbd> | |
| 268 | 269 | </button> | |
| @@ -297,13 +298,7 @@ | |||
| 297 | 298 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 298 | 299 | <div class="panel-head"> | |
| 299 | 300 | <label for="cmd-q" class="sr">go to</label> | |
| 300 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 301 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 302 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 303 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 304 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 305 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 306 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 301 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 307 | 302 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 308 | 303 | <kbd>esc</kbd> | |
| 309 | 304 | </button> | |
| @@ -374,67 +369,27 @@ | |||
| 374 | 369 | <kbd>esc</kbd> close</span> | |
| 375 | 370 | </p> | |
| 376 | 371 | </dialog> | |
| 377 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 378 | - | if (q('dialog[open]').count) return; | |
| 379 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 380 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 381 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 382 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 383 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 384 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 385 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 386 | - | })(1); | |
| 387 | - | k-k from:body -> (async (d) => { | |
| 388 | - | if (q('dialog[open]').count) return; | |
| 389 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 390 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 391 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 392 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 393 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 394 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 395 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 396 | - | })(-1); | |
| 397 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 398 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 399 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 400 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 401 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 402 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 403 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 404 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 405 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 406 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 407 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 408 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 409 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 410 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 411 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 412 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 413 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 414 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 415 | - | k-next from:body -> { | |
| 416 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 417 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 418 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 419 | - | }; | |
| 420 | - | k-split from:body -> { | |
| 421 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 422 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 423 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 424 | - | }; | |
| 425 | - | k-unified from:body -> { | |
| 426 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 427 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 428 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 429 | - | }; | |
| 430 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 431 | - | k-permalink from:body -> { | |
| 432 | - | let b = document.body.dataset; | |
| 433 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 434 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 435 | - | navigator.clipboard?.writeText(location.href); | |
| 436 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 437 | - | }"> | |
| 372 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 373 | + | k-k from:body -> ermite.move(-1); | |
| 374 | + | k-top from:body -> ermite.edge(false); | |
| 375 | + | k-bottom from:body -> ermite.edge(true); | |
| 376 | + | k-open from:body -> ermite.open(); | |
| 377 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 378 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 379 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 380 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 381 | + | k-filter from:body -> ermite.focusFilter(); | |
| 382 | + | k-gh from:body -> ermite.go('.brand'); | |
| 383 | + | k-s from:body -> ermite.tab('s'); | |
| 384 | + | k-t from:body -> ermite.tab('t'); | |
| 385 | + | k-c from:body -> ermite.tab('c'); | |
| 386 | + | k-r from:body -> ermite.tab('r'); | |
| 387 | + | k-m from:body -> ermite.tab('m'); | |
| 388 | + | k-next from:body -> ermite.nextNote(); | |
| 389 | + | k-split from:body -> ermite.layout('split'); | |
| 390 | + | k-unified from:body -> ermite.layout('unified'); | |
| 391 | + | k-collapse from:body -> ermite.collapse(); | |
| 392 | + | k-permalink from:body -> ermite.permalink()"> | |
| 438 | 393 | </div> | |
| 439 | 394 | </body> | |
| 440 | 395 | </html> | |
R cmd/ermite/testdata/web/commit-merge.html → internal/app/testdata/web/commit-merge.html +32 -77 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -102,11 +103,11 @@ | |||
| 102 | 103 | </span> | |
| 103 | 104 | <span class="grow"> | |
| 104 | 105 | </span> | |
| 105 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 106 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 106 | 107 | </a> | |
| 107 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 108 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 108 | 109 | </a> | |
| 109 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 110 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 110 | 111 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 111 | 112 | <kbd aria-hidden="true">x</kbd> | |
| 112 | 113 | </button> | |
| @@ -170,7 +171,7 @@ | |||
| 170 | 171 | </h2> | |
| 171 | 172 | <ol class="files" aria-label="Changed files"> | |
| 172 | 173 | <li> | |
| 173 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 174 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 174 | 175 | <span class="st" title="A">A</span> | |
| 175 | 176 | <span class="p" title="side.txt">side.txt</span> | |
| 176 | 177 | <span class="n"> | |
| @@ -301,7 +302,7 @@ | |||
| 301 | 302 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 302 | 303 | <div class="panel-head"> | |
| 303 | 304 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 304 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 305 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 305 | 306 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 306 | 307 | <kbd>esc</kbd> | |
| 307 | 308 | </button> | |
| @@ -336,13 +337,7 @@ | |||
| 336 | 337 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 337 | 338 | <div class="panel-head"> | |
| 338 | 339 | <label for="cmd-q" class="sr">go to</label> | |
| 339 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 340 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 341 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 342 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 343 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 344 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 345 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 340 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 346 | 341 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 347 | 342 | <kbd>esc</kbd> | |
| 348 | 343 | </button> | |
| @@ -413,67 +408,27 @@ | |||
| 413 | 408 | <kbd>esc</kbd> close</span> | |
| 414 | 409 | </p> | |
| 415 | 410 | </dialog> | |
| 416 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 417 | - | if (q('dialog[open]').count) return; | |
| 418 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 419 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 420 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 421 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 422 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 423 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 424 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 425 | - | })(1); | |
| 426 | - | k-k from:body -> (async (d) => { | |
| 427 | - | if (q('dialog[open]').count) return; | |
| 428 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 429 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 430 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 431 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 432 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 433 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 434 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 435 | - | })(-1); | |
| 436 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 437 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 438 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 439 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 440 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 441 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 442 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 443 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 444 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 445 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 446 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 447 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 448 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 449 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 450 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 451 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 452 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 453 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 454 | - | k-next from:body -> { | |
| 455 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 456 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 457 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 458 | - | }; | |
| 459 | - | k-split from:body -> { | |
| 460 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 461 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 462 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 463 | - | }; | |
| 464 | - | k-unified from:body -> { | |
| 465 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 466 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 467 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 468 | - | }; | |
| 469 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 470 | - | k-permalink from:body -> { | |
| 471 | - | let b = document.body.dataset; | |
| 472 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 473 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 474 | - | navigator.clipboard?.writeText(location.href); | |
| 475 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 476 | - | }"> | |
| 411 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 412 | + | k-k from:body -> ermite.move(-1); | |
| 413 | + | k-top from:body -> ermite.edge(false); | |
| 414 | + | k-bottom from:body -> ermite.edge(true); | |
| 415 | + | k-open from:body -> ermite.open(); | |
| 416 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 417 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 418 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 419 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 420 | + | k-filter from:body -> ermite.focusFilter(); | |
| 421 | + | k-gh from:body -> ermite.go('.brand'); | |
| 422 | + | k-s from:body -> ermite.tab('s'); | |
| 423 | + | k-t from:body -> ermite.tab('t'); | |
| 424 | + | k-c from:body -> ermite.tab('c'); | |
| 425 | + | k-r from:body -> ermite.tab('r'); | |
| 426 | + | k-m from:body -> ermite.tab('m'); | |
| 427 | + | k-next from:body -> ermite.nextNote(); | |
| 428 | + | k-split from:body -> ermite.layout('split'); | |
| 429 | + | k-unified from:body -> ermite.layout('unified'); | |
| 430 | + | k-collapse from:body -> ermite.collapse(); | |
| 431 | + | k-permalink from:body -> ermite.permalink()"> | |
| 477 | 432 | </div> | |
| 478 | 433 | </body> | |
| 479 | 434 | </html> | |
R cmd/ermite/testdata/web/commit-modify-split.html → internal/app/testdata/web/commit-modify-split.html +33 -78 renamed, 61% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -109,11 +110,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 109 | 110 | </span> | |
| 110 | 111 | <span class="grow"> | |
| 111 | 112 | </span> | |
| 112 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 113 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 113 | 114 | </a> | |
| 114 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 115 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 115 | 116 | </a> | |
| 116 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 117 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 117 | 118 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 118 | 119 | <kbd aria-hidden="true">x</kbd> | |
| 119 | 120 | </button> | |
| @@ -277,7 +278,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 277 | 278 | </h2> | |
| 278 | 279 | <ol class="files" aria-label="Changed files"> | |
| 279 | 280 | <li> | |
| 280 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 281 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 281 | 282 | <span class="st" title="M">M</span> | |
| 282 | 283 | <span class="p" title="a.txt">a.txt</span> | |
| 283 | 284 | <span class="n"> | |
| @@ -291,7 +292,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 291 | 292 | </a> | |
| 292 | 293 | </li> | |
| 293 | 294 | <li> | |
| 294 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 295 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 295 | 296 | <span class="st" title="M">M</span> | |
| 296 | 297 | <span class="p" title="run.sh">run.sh</span> | |
| 297 | 298 | <span class="n"> | |
| @@ -422,7 +423,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 422 | 423 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 423 | 424 | <div class="panel-head"> | |
| 424 | 425 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 425 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 426 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 426 | 427 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 427 | 428 | <kbd>esc</kbd> | |
| 428 | 429 | </button> | |
| @@ -457,13 +458,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 457 | 458 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 458 | 459 | <div class="panel-head"> | |
| 459 | 460 | <label for="cmd-q" class="sr">go to</label> | |
| 460 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 461 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 462 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 463 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 464 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 465 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 466 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 461 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 467 | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 468 | 463 | <kbd>esc</kbd> | |
| 469 | 464 | </button> | |
| @@ -534,67 +529,27 @@ And <b>html</b> & "quotes".</pre> | |||
| 534 | 529 | <kbd>esc</kbd> close</span> | |
| 535 | 530 | </p> | |
| 536 | 531 | </dialog> | |
| 537 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 538 | - | if (q('dialog[open]').count) return; | |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 546 | - | })(1); | |
| 547 | - | k-k from:body -> (async (d) => { | |
| 548 | - | if (q('dialog[open]').count) return; | |
| 549 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 550 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 551 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 552 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 553 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 554 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 555 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 556 | - | })(-1); | |
| 557 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 558 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 559 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 560 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 561 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 562 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 563 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 564 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 565 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 566 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 567 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 568 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 569 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 570 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 571 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 572 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 573 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 574 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 575 | - | k-next from:body -> { | |
| 576 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 577 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 578 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 579 | - | }; | |
| 580 | - | k-split from:body -> { | |
| 581 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 582 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 583 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 584 | - | }; | |
| 585 | - | k-unified from:body -> { | |
| 586 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 587 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 588 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 589 | - | }; | |
| 590 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 591 | - | k-permalink from:body -> { | |
| 592 | - | let b = document.body.dataset; | |
| 593 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 594 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 595 | - | navigator.clipboard?.writeText(location.href); | |
| 596 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 597 | - | }"> | |
| 532 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 533 | + | k-k from:body -> ermite.move(-1); | |
| 534 | + | k-top from:body -> ermite.edge(false); | |
| 535 | + | k-bottom from:body -> ermite.edge(true); | |
| 536 | + | k-open from:body -> ermite.open(); | |
| 537 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 538 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 539 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 540 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 541 | + | k-filter from:body -> ermite.focusFilter(); | |
| 542 | + | k-gh from:body -> ermite.go('.brand'); | |
| 543 | + | k-s from:body -> ermite.tab('s'); | |
| 544 | + | k-t from:body -> ermite.tab('t'); | |
| 545 | + | k-c from:body -> ermite.tab('c'); | |
| 546 | + | k-r from:body -> ermite.tab('r'); | |
| 547 | + | k-m from:body -> ermite.tab('m'); | |
| 548 | + | k-next from:body -> ermite.nextNote(); | |
| 549 | + | k-split from:body -> ermite.layout('split'); | |
| 550 | + | k-unified from:body -> ermite.layout('unified'); | |
| 551 | + | k-collapse from:body -> ermite.collapse(); | |
| 552 | + | k-permalink from:body -> ermite.permalink()"> | |
| 598 | 553 | </div> | |
| 599 | 554 | </body> | |
| 600 | 555 | </html> | |
R cmd/ermite/testdata/web/commit-modify.html → internal/app/testdata/web/commit-modify.html +33 -78 renamed, 61% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -109,11 +110,11 @@ And <b>html</b> & "quotes".</pre> | |||
| 109 | 110 | </span> | |
| 110 | 111 | <span class="grow"> | |
| 111 | 112 | </span> | |
| 112 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 113 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 113 | 114 | </a> | |
| 114 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 115 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 115 | 116 | </a> | |
| 116 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 117 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 117 | 118 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 118 | 119 | <kbd aria-hidden="true">x</kbd> | |
| 119 | 120 | </button> | |
| @@ -277,7 +278,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 277 | 278 | </h2> | |
| 278 | 279 | <ol class="files" aria-label="Changed files"> | |
| 279 | 280 | <li> | |
| 280 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 281 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 281 | 282 | <span class="st" title="M">M</span> | |
| 282 | 283 | <span class="p" title="a.txt">a.txt</span> | |
| 283 | 284 | <span class="n"> | |
| @@ -291,7 +292,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 291 | 292 | </a> | |
| 292 | 293 | </li> | |
| 293 | 294 | <li> | |
| 294 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 295 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 295 | 296 | <span class="st" title="M">M</span> | |
| 296 | 297 | <span class="p" title="run.sh">run.sh</span> | |
| 297 | 298 | <span class="n"> | |
| @@ -422,7 +423,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 422 | 423 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 423 | 424 | <div class="panel-head"> | |
| 424 | 425 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 425 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 426 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 426 | 427 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 427 | 428 | <kbd>esc</kbd> | |
| 428 | 429 | </button> | |
| @@ -457,13 +458,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 457 | 458 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 458 | 459 | <div class="panel-head"> | |
| 459 | 460 | <label for="cmd-q" class="sr">go to</label> | |
| 460 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 461 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 462 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 463 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 464 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 465 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 466 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 461 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 467 | 462 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 468 | 463 | <kbd>esc</kbd> | |
| 469 | 464 | </button> | |
| @@ -534,67 +529,27 @@ And <b>html</b> & "quotes".</pre> | |||
| 534 | 529 | <kbd>esc</kbd> close</span> | |
| 535 | 530 | </p> | |
| 536 | 531 | </dialog> | |
| 537 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 538 | - | if (q('dialog[open]').count) return; | |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 546 | - | })(1); | |
| 547 | - | k-k from:body -> (async (d) => { | |
| 548 | - | if (q('dialog[open]').count) return; | |
| 549 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 550 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 551 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 552 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 553 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 554 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 555 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 556 | - | })(-1); | |
| 557 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 558 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 559 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 560 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 561 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 562 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 563 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 564 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 565 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 566 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 567 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 568 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 569 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 570 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 571 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 572 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 573 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 574 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 575 | - | k-next from:body -> { | |
| 576 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 577 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 578 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 579 | - | }; | |
| 580 | - | k-split from:body -> { | |
| 581 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 582 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 583 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 584 | - | }; | |
| 585 | - | k-unified from:body -> { | |
| 586 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 587 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 588 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 589 | - | }; | |
| 590 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 591 | - | k-permalink from:body -> { | |
| 592 | - | let b = document.body.dataset; | |
| 593 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 594 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 595 | - | navigator.clipboard?.writeText(location.href); | |
| 596 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 597 | - | }"> | |
| 532 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 533 | + | k-k from:body -> ermite.move(-1); | |
| 534 | + | k-top from:body -> ermite.edge(false); | |
| 535 | + | k-bottom from:body -> ermite.edge(true); | |
| 536 | + | k-open from:body -> ermite.open(); | |
| 537 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 538 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 539 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 540 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 541 | + | k-filter from:body -> ermite.focusFilter(); | |
| 542 | + | k-gh from:body -> ermite.go('.brand'); | |
| 543 | + | k-s from:body -> ermite.tab('s'); | |
| 544 | + | k-t from:body -> ermite.tab('t'); | |
| 545 | + | k-c from:body -> ermite.tab('c'); | |
| 546 | + | k-r from:body -> ermite.tab('r'); | |
| 547 | + | k-m from:body -> ermite.tab('m'); | |
| 548 | + | k-next from:body -> ermite.nextNote(); | |
| 549 | + | k-split from:body -> ermite.layout('split'); | |
| 550 | + | k-unified from:body -> ermite.layout('unified'); | |
| 551 | + | k-collapse from:body -> ermite.collapse(); | |
| 552 | + | k-permalink from:body -> ermite.permalink()"> | |
| 598 | 553 | </div> | |
| 599 | 554 | </body> | |
| 600 | 555 | </html> | |
R cmd/ermite/testdata/web/commit-rename.html → internal/app/testdata/web/commit-rename.html +33 -78 renamed, 57% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -103,11 +104,11 @@ | |||
| 103 | 104 | </span> | |
| 104 | 105 | <span class="grow"> | |
| 105 | 106 | </span> | |
| 106 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 107 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 107 | 108 | </a> | |
| 108 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 109 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 109 | 110 | </a> | |
| 110 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 111 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 111 | 112 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 112 | 113 | <kbd aria-hidden="true">x</kbd> | |
| 113 | 114 | </button> | |
| @@ -181,7 +182,7 @@ | |||
| 181 | 182 | </h2> | |
| 182 | 183 | <ol class="files" aria-label="Changed files"> | |
| 183 | 184 | <li> | |
| 184 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 185 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 185 | 186 | <span class="st" title="R">R</span> | |
| 186 | 187 | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | |
| 187 | 188 | <span class="n"> | |
| @@ -196,7 +197,7 @@ | |||
| 196 | 197 | </a> | |
| 197 | 198 | </li> | |
| 198 | 199 | <li> | |
| 199 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 200 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 200 | 201 | <span class="st" title="D">D</span> | |
| 201 | 202 | <span class="p" title="gone.txt">gone.txt</span> | |
| 202 | 203 | <span class="n"> | |
| @@ -327,7 +328,7 @@ | |||
| 327 | 328 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 328 | 329 | <div class="panel-head"> | |
| 329 | 330 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 330 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 331 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 331 | 332 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 332 | 333 | <kbd>esc</kbd> | |
| 333 | 334 | </button> | |
| @@ -362,13 +363,7 @@ | |||
| 362 | 363 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 363 | 364 | <div class="panel-head"> | |
| 364 | 365 | <label for="cmd-q" class="sr">go to</label> | |
| 365 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 366 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 367 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 368 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 369 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 370 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 371 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 366 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 372 | 367 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 373 | 368 | <kbd>esc</kbd> | |
| 374 | 369 | </button> | |
| @@ -439,67 +434,27 @@ | |||
| 439 | 434 | <kbd>esc</kbd> close</span> | |
| 440 | 435 | </p> | |
| 441 | 436 | </dialog> | |
| 442 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 443 | - | if (q('dialog[open]').count) return; | |
| 444 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 445 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 446 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 447 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 448 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 449 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 450 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 451 | - | })(1); | |
| 452 | - | k-k from:body -> (async (d) => { | |
| 453 | - | if (q('dialog[open]').count) return; | |
| 454 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 455 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 456 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 457 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 458 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 459 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 460 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 461 | - | })(-1); | |
| 462 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 463 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 464 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 465 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 466 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 467 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 468 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 469 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 470 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 471 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 472 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 473 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 474 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 475 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 476 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 477 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 478 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 479 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 480 | - | k-next from:body -> { | |
| 481 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 482 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 483 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 484 | - | }; | |
| 485 | - | k-split from:body -> { | |
| 486 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 487 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 488 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 489 | - | }; | |
| 490 | - | k-unified from:body -> { | |
| 491 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 492 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 493 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 494 | - | }; | |
| 495 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 496 | - | k-permalink from:body -> { | |
| 497 | - | let b = document.body.dataset; | |
| 498 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 499 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 500 | - | navigator.clipboard?.writeText(location.href); | |
| 501 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 502 | - | }"> | |
| 437 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 438 | + | k-k from:body -> ermite.move(-1); | |
| 439 | + | k-top from:body -> ermite.edge(false); | |
| 440 | + | k-bottom from:body -> ermite.edge(true); | |
| 441 | + | k-open from:body -> ermite.open(); | |
| 442 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 443 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 444 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 445 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 446 | + | k-filter from:body -> ermite.focusFilter(); | |
| 447 | + | k-gh from:body -> ermite.go('.brand'); | |
| 448 | + | k-s from:body -> ermite.tab('s'); | |
| 449 | + | k-t from:body -> ermite.tab('t'); | |
| 450 | + | k-c from:body -> ermite.tab('c'); | |
| 451 | + | k-r from:body -> ermite.tab('r'); | |
| 452 | + | k-m from:body -> ermite.tab('m'); | |
| 453 | + | k-next from:body -> ermite.nextNote(); | |
| 454 | + | k-split from:body -> ermite.layout('split'); | |
| 455 | + | k-unified from:body -> ermite.layout('unified'); | |
| 456 | + | k-collapse from:body -> ermite.collapse(); | |
| 457 | + | k-permalink from:body -> ermite.permalink()"> | |
| 503 | 458 | </div> | |
| 504 | 459 | </body> | |
| 505 | 460 | </html> | |
R cmd/ermite/testdata/web/commit-root.html → internal/app/testdata/web/commit-root.html +36 -81 renamed, 64% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -101,11 +102,11 @@ | |||
| 101 | 102 | </span> | |
| 102 | 103 | <span class="grow"> | |
| 103 | 104 | </span> | |
| 104 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 105 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 105 | 106 | </a> | |
| 106 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 107 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 107 | 108 | </a> | |
| 108 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 109 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 109 | 110 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 110 | 111 | <kbd aria-hidden="true">x</kbd> | |
| 111 | 112 | </button> | |
| @@ -455,7 +456,7 @@ | |||
| 455 | 456 | </h2> | |
| 456 | 457 | <ol class="files" aria-label="Changed files"> | |
| 457 | 458 | <li> | |
| 458 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 459 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 459 | 460 | <span class="st" title="A">A</span> | |
| 460 | 461 | <span class="p" title="a.txt">a.txt</span> | |
| 461 | 462 | <span class="n"> | |
| @@ -470,7 +471,7 @@ | |||
| 470 | 471 | </a> | |
| 471 | 472 | </li> | |
| 472 | 473 | <li> | |
| 473 | - | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 474 | + | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 474 | 475 | <span class="st" title="A">A</span> | |
| 475 | 476 | <span class="p" title="gone.txt">gone.txt</span> | |
| 476 | 477 | <span class="n"> | |
| @@ -484,7 +485,7 @@ | |||
| 484 | 485 | </a> | |
| 485 | 486 | </li> | |
| 486 | 487 | <li> | |
| 487 | - | <a href="#f3" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f3' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 488 | + | <a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 488 | 489 | <span class="st" title="A">A</span> | |
| 489 | 490 | <span class="p" title="logo.bin">logo.bin</span> | |
| 490 | 491 | <span class="n"> | |
| @@ -499,7 +500,7 @@ | |||
| 499 | 500 | </a> | |
| 500 | 501 | </li> | |
| 501 | 502 | <li> | |
| 502 | - | <a href="#f4" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f4' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 503 | + | <a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 503 | 504 | <span class="st" title="A">A</span> | |
| 504 | 505 | <span class="p" title="old.txt">old.txt</span> | |
| 505 | 506 | <span class="n"> | |
| @@ -513,7 +514,7 @@ | |||
| 513 | 514 | </a> | |
| 514 | 515 | </li> | |
| 515 | 516 | <li> | |
| 516 | - | <a href="#f5" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f5' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 517 | + | <a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 517 | 518 | <span class="st" title="A">A</span> | |
| 518 | 519 | <span class="p" title="run.sh">run.sh</span> | |
| 519 | 520 | <span class="n"> | |
| @@ -643,7 +644,7 @@ | |||
| 643 | 644 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 644 | 645 | <div class="panel-head"> | |
| 645 | 646 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 646 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 647 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 647 | 648 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 648 | 649 | <kbd>esc</kbd> | |
| 649 | 650 | </button> | |
| @@ -678,13 +679,7 @@ | |||
| 678 | 679 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 679 | 680 | <div class="panel-head"> | |
| 680 | 681 | <label for="cmd-q" class="sr">go to</label> | |
| 681 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 682 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 683 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 684 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 685 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 686 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 687 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 682 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 688 | 683 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 689 | 684 | <kbd>esc</kbd> | |
| 690 | 685 | </button> | |
| @@ -755,67 +750,27 @@ | |||
| 755 | 750 | <kbd>esc</kbd> close</span> | |
| 756 | 751 | </p> | |
| 757 | 752 | </dialog> | |
| 758 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 759 | - | if (q('dialog[open]').count) return; | |
| 760 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 761 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 762 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 763 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 764 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 765 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 766 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 767 | - | })(1); | |
| 768 | - | k-k from:body -> (async (d) => { | |
| 769 | - | if (q('dialog[open]').count) return; | |
| 770 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 771 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 772 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 773 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 774 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 775 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 776 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 777 | - | })(-1); | |
| 778 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 779 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 780 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 781 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 782 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 783 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 784 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 785 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 786 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 787 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 788 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 789 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 790 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 791 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 792 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 793 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 794 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 795 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 796 | - | k-next from:body -> { | |
| 797 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 798 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 799 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 800 | - | }; | |
| 801 | - | k-split from:body -> { | |
| 802 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 803 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 804 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 805 | - | }; | |
| 806 | - | k-unified from:body -> { | |
| 807 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 808 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 809 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 810 | - | }; | |
| 811 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 812 | - | k-permalink from:body -> { | |
| 813 | - | let b = document.body.dataset; | |
| 814 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 815 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 816 | - | navigator.clipboard?.writeText(location.href); | |
| 817 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 818 | - | }"> | |
| 753 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 754 | + | k-k from:body -> ermite.move(-1); | |
| 755 | + | k-top from:body -> ermite.edge(false); | |
| 756 | + | k-bottom from:body -> ermite.edge(true); | |
| 757 | + | k-open from:body -> ermite.open(); | |
| 758 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 759 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 760 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 761 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 762 | + | k-filter from:body -> ermite.focusFilter(); | |
| 763 | + | k-gh from:body -> ermite.go('.brand'); | |
| 764 | + | k-s from:body -> ermite.tab('s'); | |
| 765 | + | k-t from:body -> ermite.tab('t'); | |
| 766 | + | k-c from:body -> ermite.tab('c'); | |
| 767 | + | k-r from:body -> ermite.tab('r'); | |
| 768 | + | k-m from:body -> ermite.tab('m'); | |
| 769 | + | k-next from:body -> ermite.nextNote(); | |
| 770 | + | k-split from:body -> ermite.layout('split'); | |
| 771 | + | k-unified from:body -> ermite.layout('unified'); | |
| 772 | + | k-collapse from:body -> ermite.collapse(); | |
| 773 | + | k-permalink from:body -> ermite.permalink()"> | |
| 819 | 774 | </div> | |
| 820 | 775 | </body> | |
| 821 | 776 | </html> | |
R cmd/ermite/testdata/web/commit-trojan.html → internal/app/testdata/web/commit-trojan.html +32 -77 renamed, 63% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">detached</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -104,11 +105,11 @@ | |||
| 104 | 105 | </span> | |
| 105 | 106 | <span class="grow"> | |
| 106 | 107 | </span> | |
| 107 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 108 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 108 | 109 | </a> | |
| 109 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 110 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 110 | 111 | </a> | |
| 111 | - | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))" class="btn"> | |
| 112 | + | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 112 | 113 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| 113 | 114 | <kbd aria-hidden="true">x</kbd> | |
| 114 | 115 | </button> | |
| @@ -305,7 +306,7 @@ | |||
| 305 | 306 | </h2> | |
| 306 | 307 | <ol class="files" aria-label="Changed files"> | |
| 307 | 308 | <li> | |
| 308 | - | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 309 | + | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 309 | 310 | <span class="st" title="A">A</span> | |
| 310 | 311 | <span class="p" title="evil.go">evil.go</span> | |
| 311 | 312 | <span class="n"> | |
| @@ -436,7 +437,7 @@ | |||
| 436 | 437 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 437 | 438 | <div class="panel-head"> | |
| 438 | 439 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 439 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 440 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 440 | 441 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 441 | 442 | <kbd>esc</kbd> | |
| 442 | 443 | </button> | |
| @@ -471,13 +472,7 @@ | |||
| 471 | 472 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 472 | 473 | <div class="panel-head"> | |
| 473 | 474 | <label for="cmd-q" class="sr">go to</label> | |
| 474 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 475 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 476 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 477 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 478 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 479 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 480 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 475 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 481 | 476 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 482 | 477 | <kbd>esc</kbd> | |
| 483 | 478 | </button> | |
| @@ -548,67 +543,27 @@ | |||
| 548 | 543 | <kbd>esc</kbd> close</span> | |
| 549 | 544 | </p> | |
| 550 | 545 | </dialog> | |
| 551 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 552 | - | if (q('dialog[open]').count) return; | |
| 553 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 554 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 555 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 556 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 557 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 558 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 559 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 560 | - | })(1); | |
| 561 | - | k-k from:body -> (async (d) => { | |
| 562 | - | if (q('dialog[open]').count) return; | |
| 563 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 564 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 565 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 566 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 567 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 568 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 569 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 570 | - | })(-1); | |
| 571 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 572 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 573 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 574 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 575 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 576 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 577 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 578 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 579 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 580 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 581 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 582 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 583 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 584 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 585 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 586 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 587 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 588 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 589 | - | k-next from:body -> { | |
| 590 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 591 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 592 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 593 | - | }; | |
| 594 | - | k-split from:body -> { | |
| 595 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 596 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 597 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 598 | - | }; | |
| 599 | - | k-unified from:body -> { | |
| 600 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 601 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 602 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 603 | - | }; | |
| 604 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 605 | - | k-permalink from:body -> { | |
| 606 | - | let b = document.body.dataset; | |
| 607 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 608 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 609 | - | navigator.clipboard?.writeText(location.href); | |
| 610 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 611 | - | }"> | |
| 546 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 547 | + | k-k from:body -> ermite.move(-1); | |
| 548 | + | k-top from:body -> ermite.edge(false); | |
| 549 | + | k-bottom from:body -> ermite.edge(true); | |
| 550 | + | k-open from:body -> ermite.open(); | |
| 551 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 552 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 553 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 554 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 555 | + | k-filter from:body -> ermite.focusFilter(); | |
| 556 | + | k-gh from:body -> ermite.go('.brand'); | |
| 557 | + | k-s from:body -> ermite.tab('s'); | |
| 558 | + | k-t from:body -> ermite.tab('t'); | |
| 559 | + | k-c from:body -> ermite.tab('c'); | |
| 560 | + | k-r from:body -> ermite.tab('r'); | |
| 561 | + | k-m from:body -> ermite.tab('m'); | |
| 562 | + | k-next from:body -> ermite.nextNote(); | |
| 563 | + | k-split from:body -> ermite.layout('split'); | |
| 564 | + | k-unified from:body -> ermite.layout('unified'); | |
| 565 | + | k-collapse from:body -> ermite.collapse(); | |
| 566 | + | k-permalink from:body -> ermite.permalink()"> | |
| 612 | 567 | </div> | |
| 613 | 568 | </body> | |
| 614 | 569 | </html> | |
R cmd/ermite/testdata/web/commits-page2.html → internal/app/testdata/web/commits-page2.html +29 -76 renamed, 55% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">main</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -86,9 +87,7 @@ | |||
| 86 | 87 | <main id="main" tabindex="-1"> | |
| 87 | 88 | <div class="filter"> | |
| 88 | 89 | <label for="commit-filter" class="sr">filter</label> | |
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | 91 | </div> | |
| 93 | 92 | <div class="days"> | |
| 94 | 93 | <section aria-label="2026-03-01"> | |
| @@ -259,7 +258,7 @@ | |||
| 259 | 258 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 260 | 259 | <div class="panel-head"> | |
| 261 | 260 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 262 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 261 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 263 | 262 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 264 | 263 | <kbd>esc</kbd> | |
| 265 | 264 | </button> | |
| @@ -286,13 +285,7 @@ | |||
| 286 | 285 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 287 | 286 | <div class="panel-head"> | |
| 288 | 287 | <label for="cmd-q" class="sr">go to</label> | |
| 289 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 290 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 291 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 292 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 293 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 294 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 295 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 288 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 296 | 289 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 297 | 290 | <kbd>esc</kbd> | |
| 298 | 291 | </button> | |
| @@ -357,67 +350,27 @@ | |||
| 357 | 350 | <kbd>esc</kbd> close</span> | |
| 358 | 351 | </p> | |
| 359 | 352 | </dialog> | |
| 360 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 361 | - | if (q('dialog[open]').count) return; | |
| 362 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 363 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 364 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 365 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 366 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 367 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 368 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 369 | - | })(1); | |
| 370 | - | k-k from:body -> (async (d) => { | |
| 371 | - | if (q('dialog[open]').count) return; | |
| 372 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 373 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 374 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 375 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 376 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 377 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 378 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 379 | - | })(-1); | |
| 380 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 381 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 382 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 383 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 384 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 385 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 386 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 387 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 388 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 389 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 390 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 391 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 392 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 393 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 394 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 395 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 396 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 397 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 398 | - | k-next from:body -> { | |
| 399 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 400 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 401 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 402 | - | }; | |
| 403 | - | k-split from:body -> { | |
| 404 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 405 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 406 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 407 | - | }; | |
| 408 | - | k-unified from:body -> { | |
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 410 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 412 | - | }; | |
| 413 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 414 | - | k-permalink from:body -> { | |
| 415 | - | let b = document.body.dataset; | |
| 416 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 417 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 418 | - | navigator.clipboard?.writeText(location.href); | |
| 419 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 420 | - | }"> | |
| 353 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 354 | + | k-k from:body -> ermite.move(-1); | |
| 355 | + | k-top from:body -> ermite.edge(false); | |
| 356 | + | k-bottom from:body -> ermite.edge(true); | |
| 357 | + | k-open from:body -> ermite.open(); | |
| 358 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 359 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 360 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 361 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 362 | + | k-filter from:body -> ermite.focusFilter(); | |
| 363 | + | k-gh from:body -> ermite.go('.brand'); | |
| 364 | + | k-s from:body -> ermite.tab('s'); | |
| 365 | + | k-t from:body -> ermite.tab('t'); | |
| 366 | + | k-c from:body -> ermite.tab('c'); | |
| 367 | + | k-r from:body -> ermite.tab('r'); | |
| 368 | + | k-m from:body -> ermite.tab('m'); | |
| 369 | + | k-next from:body -> ermite.nextNote(); | |
| 370 | + | k-split from:body -> ermite.layout('split'); | |
| 371 | + | k-unified from:body -> ermite.layout('unified'); | |
| 372 | + | k-collapse from:body -> ermite.collapse(); | |
| 373 | + | k-permalink from:body -> ermite.permalink()"> | |
| 421 | 374 | </div> | |
| 422 | 375 | </body> | |
| 423 | 376 | </html> | |
R cmd/ermite/testdata/web/commits-path.html → internal/app/testdata/web/commits-path.html +29 -76 renamed, 56% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -89,9 +90,7 @@ | |||
| 89 | 90 | <main id="main" tabindex="-1"> | |
| 90 | 91 | <div class="filter"> | |
| 91 | 92 | <label for="commit-filter" class="sr">filter</label> | |
| 92 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 93 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 94 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 93 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 95 | 94 | </div> | |
| 96 | 95 | <p class="lede dim">history of <code>a.txt</code> at main</p> | |
| 97 | 96 | <div class="days"> | |
| @@ -274,7 +273,7 @@ | |||
| 274 | 273 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 275 | 274 | <div class="panel-head"> | |
| 276 | 275 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 277 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 276 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 278 | 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 279 | 278 | <kbd>esc</kbd> | |
| 280 | 279 | </button> | |
| @@ -301,13 +300,7 @@ | |||
| 301 | 300 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 302 | 301 | <div class="panel-head"> | |
| 303 | 302 | <label for="cmd-q" class="sr">go to</label> | |
| 304 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 305 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 306 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 307 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 308 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 309 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 310 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 303 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 311 | 304 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 312 | 305 | <kbd>esc</kbd> | |
| 313 | 306 | </button> | |
| @@ -372,67 +365,27 @@ | |||
| 372 | 365 | <kbd>esc</kbd> close</span> | |
| 373 | 366 | </p> | |
| 374 | 367 | </dialog> | |
| 375 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 376 | - | if (q('dialog[open]').count) return; | |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 384 | - | })(1); | |
| 385 | - | k-k from:body -> (async (d) => { | |
| 386 | - | if (q('dialog[open]').count) return; | |
| 387 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 388 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 389 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 390 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 391 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 392 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 393 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 394 | - | })(-1); | |
| 395 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 396 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 397 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 398 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 399 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 400 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 401 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 402 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 403 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 404 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 405 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 406 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 407 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 408 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 409 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 410 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 411 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 412 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 413 | - | k-next from:body -> { | |
| 414 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 415 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 416 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 417 | - | }; | |
| 418 | - | k-split from:body -> { | |
| 419 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 420 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 421 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 422 | - | }; | |
| 423 | - | k-unified from:body -> { | |
| 424 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 425 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 426 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 427 | - | }; | |
| 428 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 429 | - | k-permalink from:body -> { | |
| 430 | - | let b = document.body.dataset; | |
| 431 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 432 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 433 | - | navigator.clipboard?.writeText(location.href); | |
| 434 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 435 | - | }"> | |
| 368 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 369 | + | k-k from:body -> ermite.move(-1); | |
| 370 | + | k-top from:body -> ermite.edge(false); | |
| 371 | + | k-bottom from:body -> ermite.edge(true); | |
| 372 | + | k-open from:body -> ermite.open(); | |
| 373 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 374 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 375 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 376 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 377 | + | k-filter from:body -> ermite.focusFilter(); | |
| 378 | + | k-gh from:body -> ermite.go('.brand'); | |
| 379 | + | k-s from:body -> ermite.tab('s'); | |
| 380 | + | k-t from:body -> ermite.tab('t'); | |
| 381 | + | k-c from:body -> ermite.tab('c'); | |
| 382 | + | k-r from:body -> ermite.tab('r'); | |
| 383 | + | k-m from:body -> ermite.tab('m'); | |
| 384 | + | k-next from:body -> ermite.nextNote(); | |
| 385 | + | k-split from:body -> ermite.layout('split'); | |
| 386 | + | k-unified from:body -> ermite.layout('unified'); | |
| 387 | + | k-collapse from:body -> ermite.collapse(); | |
| 388 | + | k-permalink from:body -> ermite.permalink()"> | |
| 436 | 389 | </div> | |
| 437 | 390 | </body> | |
| 438 | 391 | </html> | |
R cmd/ermite/testdata/web/commits-root.html → internal/app/testdata/web/commits-root.html +32 -77 renamed, 79% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/long/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">main</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -86,11 +87,9 @@ | |||
| 86 | 87 | <main id="main" tabindex="-1"> | |
| 87 | 88 | <div class="filter"> | |
| 88 | 89 | <label for="commit-filter" class="sr">filter</label> | |
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | 91 | </div> | |
| 93 | - | <div id="commits-head"> | |
| 92 | + | <div id="commits-head" class="live-slot" role="status" data-slot> | |
| 94 | 93 | </div> | |
| 95 | 94 | <div class="days"> | |
| 96 | 95 | <section aria-label="2026-03-26"> | |
| @@ -958,6 +957,8 @@ | |||
| 958 | 957 | </span> | |
| 959 | 958 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Along&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 960 | 959 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 960 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 961 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 961 | 962 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 962 | 963 | gap -> ermiteLive.reload()"> | |
| 963 | 964 | </span> | |
| @@ -1061,7 +1062,7 @@ | |||
| 1061 | 1062 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 1062 | 1063 | <div class="panel-head"> | |
| 1063 | 1064 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 1064 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 1065 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 1065 | 1066 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 1066 | 1067 | <kbd>esc</kbd> | |
| 1067 | 1068 | </button> | |
| @@ -1088,13 +1089,7 @@ | |||
| 1088 | 1089 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 1089 | 1090 | <div class="panel-head"> | |
| 1090 | 1091 | <label for="cmd-q" class="sr">go to</label> | |
| 1091 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 1092 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 1093 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 1094 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 1095 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 1096 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 1097 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 1092 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 1098 | 1093 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 1099 | 1094 | <kbd>esc</kbd> | |
| 1100 | 1095 | </button> | |
| @@ -1159,67 +1154,27 @@ | |||
| 1159 | 1154 | <kbd>esc</kbd> close</span> | |
| 1160 | 1155 | </p> | |
| 1161 | 1156 | </dialog> | |
| 1162 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 1163 | - | if (q('dialog[open]').count) return; | |
| 1164 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1165 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1166 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1167 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 1168 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 1169 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 1170 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 1171 | - | })(1); | |
| 1172 | - | k-k from:body -> (async (d) => { | |
| 1173 | - | if (q('dialog[open]').count) return; | |
| 1174 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1175 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1176 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1177 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 1178 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 1179 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 1180 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 1181 | - | })(-1); | |
| 1182 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 1183 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 1184 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 1185 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 1186 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 1187 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 1188 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 1189 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 1190 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 1191 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 1192 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 1193 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 1194 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 1195 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 1196 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 1197 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 1198 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 1199 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 1200 | - | k-next from:body -> { | |
| 1201 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 1202 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 1203 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 1204 | - | }; | |
| 1205 | - | k-split from:body -> { | |
| 1206 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 1207 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 1208 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 1209 | - | }; | |
| 1210 | - | k-unified from:body -> { | |
| 1211 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 1212 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 1213 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 1214 | - | }; | |
| 1215 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 1216 | - | k-permalink from:body -> { | |
| 1217 | - | let b = document.body.dataset; | |
| 1218 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 1219 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 1220 | - | navigator.clipboard?.writeText(location.href); | |
| 1221 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 1222 | - | }"> | |
| 1157 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 1158 | + | k-k from:body -> ermite.move(-1); | |
| 1159 | + | k-top from:body -> ermite.edge(false); | |
| 1160 | + | k-bottom from:body -> ermite.edge(true); | |
| 1161 | + | k-open from:body -> ermite.open(); | |
| 1162 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 1163 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 1164 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 1165 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 1166 | + | k-filter from:body -> ermite.focusFilter(); | |
| 1167 | + | k-gh from:body -> ermite.go('.brand'); | |
| 1168 | + | k-s from:body -> ermite.tab('s'); | |
| 1169 | + | k-t from:body -> ermite.tab('t'); | |
| 1170 | + | k-c from:body -> ermite.tab('c'); | |
| 1171 | + | k-r from:body -> ermite.tab('r'); | |
| 1172 | + | k-m from:body -> ermite.tab('m'); | |
| 1173 | + | k-next from:body -> ermite.nextNote(); | |
| 1174 | + | k-split from:body -> ermite.layout('split'); | |
| 1175 | + | k-unified from:body -> ermite.layout('unified'); | |
| 1176 | + | k-collapse from:body -> ermite.collapse(); | |
| 1177 | + | k-permalink from:body -> ermite.permalink()"> | |
| 1223 | 1178 | </div> | |
| 1224 | 1179 | </body> | |
| 1225 | 1180 | </html> | |
R cmd/ermite/testdata/web/commits-small.html → internal/app/testdata/web/commits-small.html +32 -77 renamed, 64% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/diffs/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">main</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -86,11 +87,9 @@ | |||
| 86 | 87 | <main id="main" tabindex="-1"> | |
| 87 | 88 | <div class="filter"> | |
| 88 | 89 | <label for="commit-filter" class="sr">filter</label> | |
| 89 | - | <input id="commit-filter" type="search" data-filter placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 90 | - | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 91 | - | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 90 | + | <input id="commit-filter" type="search" data-filter=".days tr" data-groups=".days section" placeholder="Filter by subject, sha or author…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 92 | 91 | </div> | |
| 93 | - | <div id="commits-head"> | |
| 92 | + | <div id="commits-head" class="live-slot" role="status" data-slot> | |
| 94 | 93 | </div> | |
| 95 | 94 | <div class="days"> | |
| 96 | 95 | <section aria-label="2026-02-08"> | |
| @@ -303,6 +302,8 @@ | |||
| 303 | 302 | </span> | |
| 304 | 303 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Adiffs&view=commits&ref=main&head=SHA40" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 305 | 304 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 305 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 306 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 306 | 307 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 307 | 308 | gap -> ermiteLive.reload()"> | |
| 308 | 309 | </span> | |
| @@ -406,7 +407,7 @@ | |||
| 406 | 407 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 407 | 408 | <div class="panel-head"> | |
| 408 | 409 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 409 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 410 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 410 | 411 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 411 | 412 | <kbd>esc</kbd> | |
| 412 | 413 | </button> | |
| @@ -441,13 +442,7 @@ | |||
| 441 | 442 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 442 | 443 | <div class="panel-head"> | |
| 443 | 444 | <label for="cmd-q" class="sr">go to</label> | |
| 444 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 445 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 446 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 447 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 448 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 449 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 450 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 445 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 451 | 446 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 452 | 447 | <kbd>esc</kbd> | |
| 453 | 448 | </button> | |
| @@ -524,67 +519,27 @@ | |||
| 524 | 519 | <kbd>esc</kbd> close</span> | |
| 525 | 520 | </p> | |
| 526 | 521 | </dialog> | |
| 527 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 528 | - | if (q('dialog[open]').count) return; | |
| 529 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 530 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 531 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 532 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 533 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 534 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 535 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 536 | - | })(1); | |
| 537 | - | k-k from:body -> (async (d) => { | |
| 538 | - | if (q('dialog[open]').count) return; | |
| 539 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 540 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 541 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 542 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 543 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 544 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 545 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 546 | - | })(-1); | |
| 547 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 548 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 549 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 550 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 551 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 552 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 553 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 554 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 555 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 556 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 557 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 558 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 559 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 560 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 561 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 562 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 563 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 564 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 565 | - | k-next from:body -> { | |
| 566 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 567 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 568 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 569 | - | }; | |
| 570 | - | k-split from:body -> { | |
| 571 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 572 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 573 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 574 | - | }; | |
| 575 | - | k-unified from:body -> { | |
| 576 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 577 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 578 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 579 | - | }; | |
| 580 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 581 | - | k-permalink from:body -> { | |
| 582 | - | let b = document.body.dataset; | |
| 583 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 584 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 585 | - | navigator.clipboard?.writeText(location.href); | |
| 586 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 587 | - | }"> | |
| 522 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 523 | + | k-k from:body -> ermite.move(-1); | |
| 524 | + | k-top from:body -> ermite.edge(false); | |
| 525 | + | k-bottom from:body -> ermite.edge(true); | |
| 526 | + | k-open from:body -> ermite.open(); | |
| 527 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 528 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 529 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 530 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 531 | + | k-filter from:body -> ermite.focusFilter(); | |
| 532 | + | k-gh from:body -> ermite.go('.brand'); | |
| 533 | + | k-s from:body -> ermite.tab('s'); | |
| 534 | + | k-t from:body -> ermite.tab('t'); | |
| 535 | + | k-c from:body -> ermite.tab('c'); | |
| 536 | + | k-r from:body -> ermite.tab('r'); | |
| 537 | + | k-m from:body -> ermite.tab('m'); | |
| 538 | + | k-next from:body -> ermite.nextNote(); | |
| 539 | + | k-split from:body -> ermite.layout('split'); | |
| 540 | + | k-unified from:body -> ermite.layout('unified'); | |
| 541 | + | k-collapse from:body -> ermite.collapse(); | |
| 542 | + | k-permalink from:body -> ermite.permalink()"> | |
| 588 | 543 | </div> | |
| 589 | 544 | </body> | |
| 590 | 545 | </html> | |
A internal/app/testdata/web/empty.html +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>quiet · example.test</title> | |
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 13 | + | </script> | |
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 15 | + | </script> | |
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 17 | + | </script> | |
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | + | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 22 | + | </head> | |
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | + | <a class="skip" href="#main">skip to content</a> | |
| 25 | + | <header class="hdr"> | |
| 26 | + | <div class="hdr-top"> | |
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |
| 29 | + | </span> | |
| 30 | + | <span class="brand-name">example.test</span> | |
| 31 | + | </a> | |
| 32 | + | <nav class="crumbs" aria-label="Path"> | |
| 33 | + | <ol> | |
| 34 | + | <li> | |
| 35 | + | <a href="/quiet" aria-current="page">quiet</a> | |
| 36 | + | </li> | |
| 37 | + | </ol> | |
| 38 | + | </nav> | |
| 39 | + | <div class="hdr-acts"> | |
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |
| 42 | + | </span> | |
| 43 | + | <span class="search-label">Go to…</span> | |
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |
| 45 | + | </button> | |
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 47 | + | </div> | |
| 48 | + | </div> | |
| 49 | + | </header> | |
| 50 | + | <div class="work"> | |
| 51 | + | <main id="main" tabindex="-1"> | |
| 52 | + | <section class="state" aria-labelledby="state-title"> | |
| 53 | + | <p class="eyebrow">empty</p> | |
| 54 | + | <h1 id="state-title">quiet is empty</h1> | |
| 55 | + | <p class="dim">no commits yet. push a branch to create one.</p> | |
| 56 | + | <div class="clone" id="clone-quiet"> | |
| 57 | + | <div class="clone-line"> | |
| 58 | + | <span class="mute" aria-hidden="true">$ </span> | |
| 59 | + | <code>git remote add origin http://example.test/quiet.git</code> | |
| 60 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="ermite.copy(this)">Copy</button> | |
| 61 | + | </div> | |
| 62 | + | <div class="clone-line"> | |
| 63 | + | <span class="mute" aria-hidden="true">$ </span> | |
| 64 | + | <code>git push -u origin main</code> | |
| 65 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git push -u origin main" hx-on:click="ermite.copy(this)">Copy</button> | |
| 66 | + | </div> | |
| 67 | + | </div> | |
| 68 | + | <p class="state-back"> | |
| 69 | + | <a href="/">home</a> | |
| 70 | + | </p> | |
| 71 | + | </section> | |
| 72 | + | </main> | |
| 73 | + | </div> | |
| 74 | + | <footer class="foot"> | |
| 75 | + | <span>ermite devel</span> | |
| 76 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 77 | + | <span class="foot-keys"> | |
| 78 | + | <kbd>?</kbd> keyboard shortcuts</span> | |
| 79 | + | </footer> | |
| 80 | + | <div class="hint" role="status" aria-live="polite"> | |
| 81 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 82 | + | </span> | |
| 83 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 84 | + | </span> | |
| 85 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 86 | + | </span> | |
| 87 | + | </div> | |
| 88 | + | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 89 | + | <div class="panel-head"> | |
| 90 | + | <h2>Keyboard shortcuts</h2> | |
| 91 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 92 | + | <kbd>esc</kbd> close</button> | |
| 93 | + | </div> | |
| 94 | + | <dl class="keys"> | |
| 95 | + | <div> | |
| 96 | + | <dt> | |
| 97 | + | <kbd>ctrl k</kbd> | |
| 98 | + | <kbd>:</kbd> | |
| 99 | + | </dt> | |
| 100 | + | <dd>go to a section, branch, tag, file or repository</dd> | |
| 101 | + | </div> | |
| 102 | + | <div> | |
| 103 | + | <dt> | |
| 104 | + | <kbd>?</kbd> | |
| 105 | + | </dt> | |
| 106 | + | <dd>this list</dd> | |
| 107 | + | </div> | |
| 108 | + | <div> | |
| 109 | + | <dt> | |
| 110 | + | <kbd>j</kbd> | |
| 111 | + | <kbd>k</kbd> | |
| 112 | + | </dt> | |
| 113 | + | <dd>move the row cursor down / up</dd> | |
| 114 | + | </div> | |
| 115 | + | <div> | |
| 116 | + | <dt> | |
| 117 | + | <kbd>enter</kbd> | |
| 118 | + | </dt> | |
| 119 | + | <dd>open the row under the cursor</dd> | |
| 120 | + | </div> | |
| 121 | + | <div> | |
| 122 | + | <dt> | |
| 123 | + | <kbd>g g</kbd> | |
| 124 | + | <kbd>G</kbd> | |
| 125 | + | </dt> | |
| 126 | + | <dd>first / last row</dd> | |
| 127 | + | </div> | |
| 128 | + | <div> | |
| 129 | + | <dt> | |
| 130 | + | <kbd>/</kbd> | |
| 131 | + | </dt> | |
| 132 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 133 | + | </div> | |
| 134 | + | <div> | |
| 135 | + | <dt> | |
| 136 | + | <kbd>b</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>switch branch or tag</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>s</kbd> | |
| 143 | + | <kbd>t</kbd> | |
| 144 | + | <kbd>c</kbd> | |
| 145 | + | <kbd>r</kbd> | |
| 146 | + | <kbd>m</kbd> | |
| 147 | + | </dt> | |
| 148 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 149 | + | </div> | |
| 150 | + | <div> | |
| 151 | + | <dt> | |
| 152 | + | <kbd>g h</kbd> | |
| 153 | + | </dt> | |
| 154 | + | <dd>home</dd> | |
| 155 | + | </div> | |
| 156 | + | <div> | |
| 157 | + | <dt> | |
| 158 | + | <kbd>y</kbd> | |
| 159 | + | </dt> | |
| 160 | + | <dd>copy a permalink pinned to the commit</dd> | |
| 161 | + | </div> | |
| 162 | + | <div> | |
| 163 | + | <dt> | |
| 164 | + | <kbd>d</kbd> | |
| 165 | + | <kbd>u</kbd> | |
| 166 | + | <kbd>x</kbd> | |
| 167 | + | </dt> | |
| 168 | + | <dd>diff: split / unified / collapse or expand all</dd> | |
| 169 | + | </div> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>n</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>merge request: next note in the diff</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>close a dialog, leave a field</dd> | |
| 181 | + | </div> | |
| 182 | + | </dl> | |
| 183 | + | </dialog> | |
| 184 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 185 | + | <div class="panel-head"> | |
| 186 | + | <label for="cmd-q" class="sr">go to</label> | |
| 187 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 188 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 189 | + | <kbd>esc</kbd> | |
| 190 | + | </button> | |
| 191 | + | </div> | |
| 192 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 193 | + | <li data-k="go home" data-cursor> | |
| 194 | + | <a href="/" tabindex="-1"> | |
| 195 | + | <span class="lbl">home</span> | |
| 196 | + | <span class="k">go</span> | |
| 197 | + | </a> | |
| 198 | + | </li> | |
| 199 | + | <li data-k="repo demo"> | |
| 200 | + | <a href="/demo" tabindex="-1"> | |
| 201 | + | <span class="lbl">demo</span> | |
| 202 | + | <span class="k">repo</span> | |
| 203 | + | </a> | |
| 204 | + | </li> | |
| 205 | + | </ul> | |
| 206 | + | <p class="panel-foot"> | |
| 207 | + | <span> | |
| 208 | + | <kbd>↑</kbd> | |
| 209 | + | <kbd>↓</kbd> move</span> | |
| 210 | + | <span> | |
| 211 | + | <kbd>enter</kbd> open</span> | |
| 212 | + | <span> | |
| 213 | + | <kbd>esc</kbd> close</span> | |
| 214 | + | </p> | |
| 215 | + | </dialog> | |
| 216 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 217 | + | k-k from:body -> ermite.move(-1); | |
| 218 | + | k-top from:body -> ermite.edge(false); | |
| 219 | + | k-bottom from:body -> ermite.edge(true); | |
| 220 | + | k-open from:body -> ermite.open(); | |
| 221 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 222 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 223 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 224 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 225 | + | k-filter from:body -> ermite.focusFilter(); | |
| 226 | + | k-gh from:body -> ermite.go('.brand'); | |
| 227 | + | k-s from:body -> ermite.tab('s'); | |
| 228 | + | k-t from:body -> ermite.tab('t'); | |
| 229 | + | k-c from:body -> ermite.tab('c'); | |
| 230 | + | k-r from:body -> ermite.tab('r'); | |
| 231 | + | k-m from:body -> ermite.tab('m'); | |
| 232 | + | k-next from:body -> ermite.nextNote(); | |
| 233 | + | k-split from:body -> ermite.layout('split'); | |
| 234 | + | k-unified from:body -> ermite.layout('unified'); | |
| 235 | + | k-collapse from:body -> ermite.collapse(); | |
| 236 | + | k-permalink from:body -> ermite.permalink()"> | |
| 237 | + | </div> | |
| 238 | + | </body> | |
| 239 | + | </html> | |
A internal/app/testdata/web/home.html +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>example.test</title> | |
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 13 | + | </script> | |
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 15 | + | </script> | |
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 17 | + | </script> | |
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | + | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 22 | + | </head> | |
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | + | <a class="skip" href="#main">skip to content</a> | |
| 25 | + | <header class="hdr"> | |
| 26 | + | <div class="hdr-top"> | |
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |
| 29 | + | </span> | |
| 30 | + | <span class="brand-name">example.test</span> | |
| 31 | + | </a> | |
| 32 | + | <div class="hdr-acts"> | |
| 33 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 34 | + | <span class="ico i-search" aria-hidden="true"> | |
| 35 | + | </span> | |
| 36 | + | <span class="search-label">Go to…</span> | |
| 37 | + | <kbd aria-hidden="true">ctrl k</kbd> | |
| 38 | + | </button> | |
| 39 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 40 | + | </div> | |
| 41 | + | </div> | |
| 42 | + | </header> | |
| 43 | + | <div class="work"> | |
| 44 | + | <main id="main" tabindex="-1"> | |
| 45 | + | <div class="filter"> | |
| 46 | + | <label for="home-filter" class="sr">filter</label> | |
| 47 | + | <input id="home-filter" type="search" data-filter="#repo-rows > tr" placeholder="Filter repositories…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 48 | + | </div> | |
| 49 | + | <table class="list home" aria-label="Repositories"> | |
| 50 | + | <thead> | |
| 51 | + | <tr> | |
| 52 | + | <th class="cur"> | |
| 53 | + | </th> | |
| 54 | + | <th class="c-name" aria-sort="none"> | |
| 55 | + | <a href="/?sort=name&dir=asc">name </a> | |
| 56 | + | </th> | |
| 57 | + | <th class="c-desc" aria-sort="none"> | |
| 58 | + | <a href="/?sort=desc&dir=asc">description </a> | |
| 59 | + | </th> | |
| 60 | + | <th class="c-when" aria-sort="descending"> | |
| 61 | + | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |
| 62 | + | </a> | |
| 63 | + | </th> | |
| 64 | + | <th class="c-ref" aria-sort="none"> | |
| 65 | + | <a href="/?sort=branch&dir=asc">branch </a> | |
| 66 | + | </th> | |
| 67 | + | </tr> | |
| 68 | + | </thead> | |
| 69 | + | <tbody id="repo-rows" data-rows> | |
| 70 | + | <tr id="repo-demo" data-cursor> | |
| 71 | + | <td class="cur" aria-hidden="true"> | |
| 72 | + | </td> | |
| 73 | + | <td class="c-name"> | |
| 74 | + | <a href="/demo">demo</a> | |
| 75 | + | </td> | |
| 76 | + | <td class="c-desc dim">A demo repository</td> | |
| 77 | + | <td class="c-when nowrap"> | |
| 78 | + | <time datetime="STAMP">AGO</time> | |
| 79 | + | </td> | |
| 80 | + | <td class="c-ref">main</td> | |
| 81 | + | </tr> | |
| 82 | + | <tr id="repo-quiet"> | |
| 83 | + | <td class="cur" aria-hidden="true"> | |
| 84 | + | </td> | |
| 85 | + | <td class="c-name"> | |
| 86 | + | <a href="/quiet">quiet</a> | |
| 87 | + | </td> | |
| 88 | + | <td class="c-desc dim"> | |
| 89 | + | </td> | |
| 90 | + | <td class="c-when nowrap"> | |
| 91 | + | <time>never</time> | |
| 92 | + | </td> | |
| 93 | + | <td class="c-ref">main</td> | |
| 94 | + | </tr> | |
| 95 | + | </tbody> | |
| 96 | + | </table> | |
| 97 | + | </main> | |
| 98 | + | </div> | |
| 99 | + | <footer class="foot"> | |
| 100 | + | <span>ermite devel</span> | |
| 101 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 102 | + | <span class="foot-keys"> | |
| 103 | + | <kbd>?</kbd> keyboard shortcuts</span> | |
| 104 | + | </footer> | |
| 105 | + | <div class="hint" role="status" aria-live="polite"> | |
| 106 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 107 | + | </span> | |
| 108 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 109 | + | </span> | |
| 110 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 111 | + | </span> | |
| 112 | + | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 113 | + | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 114 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 115 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 116 | + | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 117 | + | gap -> ermiteLive.reload()"> | |
| 118 | + | </span> | |
| 119 | + | </div> | |
| 120 | + | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 121 | + | <div class="panel-head"> | |
| 122 | + | <h2>Keyboard shortcuts</h2> | |
| 123 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 124 | + | <kbd>esc</kbd> close</button> | |
| 125 | + | </div> | |
| 126 | + | <dl class="keys"> | |
| 127 | + | <div> | |
| 128 | + | <dt> | |
| 129 | + | <kbd>ctrl k</kbd> | |
| 130 | + | <kbd>:</kbd> | |
| 131 | + | </dt> | |
| 132 | + | <dd>go to a section, branch, tag, file or repository</dd> | |
| 133 | + | </div> | |
| 134 | + | <div> | |
| 135 | + | <dt> | |
| 136 | + | <kbd>?</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>this list</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>j</kbd> | |
| 143 | + | <kbd>k</kbd> | |
| 144 | + | </dt> | |
| 145 | + | <dd>move the row cursor down / up</dd> | |
| 146 | + | </div> | |
| 147 | + | <div> | |
| 148 | + | <dt> | |
| 149 | + | <kbd>enter</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>open the row under the cursor</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>g g</kbd> | |
| 156 | + | <kbd>G</kbd> | |
| 157 | + | </dt> | |
| 158 | + | <dd>first / last row</dd> | |
| 159 | + | </div> | |
| 160 | + | <div> | |
| 161 | + | <dt> | |
| 162 | + | <kbd>/</kbd> | |
| 163 | + | </dt> | |
| 164 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 165 | + | </div> | |
| 166 | + | <div> | |
| 167 | + | <dt> | |
| 168 | + | <kbd>b</kbd> | |
| 169 | + | </dt> | |
| 170 | + | <dd>switch branch or tag</dd> | |
| 171 | + | </div> | |
| 172 | + | <div> | |
| 173 | + | <dt> | |
| 174 | + | <kbd>s</kbd> | |
| 175 | + | <kbd>t</kbd> | |
| 176 | + | <kbd>c</kbd> | |
| 177 | + | <kbd>r</kbd> | |
| 178 | + | <kbd>m</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 181 | + | </div> | |
| 182 | + | <div> | |
| 183 | + | <dt> | |
| 184 | + | <kbd>g h</kbd> | |
| 185 | + | </dt> | |
| 186 | + | <dd>home</dd> | |
| 187 | + | </div> | |
| 188 | + | <div> | |
| 189 | + | <dt> | |
| 190 | + | <kbd>y</kbd> | |
| 191 | + | </dt> | |
| 192 | + | <dd>copy a permalink pinned to the commit</dd> | |
| 193 | + | </div> | |
| 194 | + | <div> | |
| 195 | + | <dt> | |
| 196 | + | <kbd>d</kbd> | |
| 197 | + | <kbd>u</kbd> | |
| 198 | + | <kbd>x</kbd> | |
| 199 | + | </dt> | |
| 200 | + | <dd>diff: split / unified / collapse or expand all</dd> | |
| 201 | + | </div> | |
| 202 | + | <div> | |
| 203 | + | <dt> | |
| 204 | + | <kbd>n</kbd> | |
| 205 | + | </dt> | |
| 206 | + | <dd>merge request: next note in the diff</dd> | |
| 207 | + | </div> | |
| 208 | + | <div> | |
| 209 | + | <dt> | |
| 210 | + | <kbd>esc</kbd> | |
| 211 | + | </dt> | |
| 212 | + | <dd>close a dialog, leave a field</dd> | |
| 213 | + | </div> | |
| 214 | + | </dl> | |
| 215 | + | </dialog> | |
| 216 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 217 | + | <div class="panel-head"> | |
| 218 | + | <label for="cmd-q" class="sr">go to</label> | |
| 219 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 220 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 221 | + | <kbd>esc</kbd> | |
| 222 | + | </button> | |
| 223 | + | </div> | |
| 224 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 225 | + | <li data-k="go home" data-cursor> | |
| 226 | + | <a href="/" tabindex="-1"> | |
| 227 | + | <span class="lbl">home</span> | |
| 228 | + | <span class="k">go</span> | |
| 229 | + | </a> | |
| 230 | + | </li> | |
| 231 | + | <li data-k="repo demo"> | |
| 232 | + | <a href="/demo" tabindex="-1"> | |
| 233 | + | <span class="lbl">demo</span> | |
| 234 | + | <span class="k">repo</span> | |
| 235 | + | </a> | |
| 236 | + | </li> | |
| 237 | + | <li data-k="repo quiet"> | |
| 238 | + | <a href="/quiet" tabindex="-1"> | |
| 239 | + | <span class="lbl">quiet</span> | |
| 240 | + | <span class="k">repo</span> | |
| 241 | + | </a> | |
| 242 | + | </li> | |
| 243 | + | </ul> | |
| 244 | + | <p class="panel-foot"> | |
| 245 | + | <span> | |
| 246 | + | <kbd>↑</kbd> | |
| 247 | + | <kbd>↓</kbd> move</span> | |
| 248 | + | <span> | |
| 249 | + | <kbd>enter</kbd> open</span> | |
| 250 | + | <span> | |
| 251 | + | <kbd>esc</kbd> close</span> | |
| 252 | + | </p> | |
| 253 | + | </dialog> | |
| 254 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 255 | + | k-k from:body -> ermite.move(-1); | |
| 256 | + | k-top from:body -> ermite.edge(false); | |
| 257 | + | k-bottom from:body -> ermite.edge(true); | |
| 258 | + | k-open from:body -> ermite.open(); | |
| 259 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 260 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 261 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 262 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 263 | + | k-filter from:body -> ermite.focusFilter(); | |
| 264 | + | k-gh from:body -> ermite.go('.brand'); | |
| 265 | + | k-s from:body -> ermite.tab('s'); | |
| 266 | + | k-t from:body -> ermite.tab('t'); | |
| 267 | + | k-c from:body -> ermite.tab('c'); | |
| 268 | + | k-r from:body -> ermite.tab('r'); | |
| 269 | + | k-m from:body -> ermite.tab('m'); | |
| 270 | + | k-next from:body -> ermite.nextNote(); | |
| 271 | + | k-split from:body -> ermite.layout('split'); | |
| 272 | + | k-unified from:body -> ermite.layout('unified'); | |
| 273 | + | k-collapse from:body -> ermite.collapse(); | |
| 274 | + | k-permalink from:body -> ermite.permalink()"> | |
| 275 | + | </div> | |
| 276 | + | </body> | |
| 277 | + | </html> | |
A internal/app/testdata/web/not-found-page.html +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>404 · example.test</title> | |
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 13 | + | </script> | |
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 15 | + | </script> | |
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 17 | + | </script> | |
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | + | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 22 | + | </head> | |
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | + | <a class="skip" href="#main">skip to content</a> | |
| 25 | + | <header class="hdr"> | |
| 26 | + | <div class="hdr-top"> | |
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |
| 29 | + | </span> | |
| 30 | + | <span class="brand-name">example.test</span> | |
| 31 | + | </a> | |
| 32 | + | <nav class="crumbs" aria-label="Path"> | |
| 33 | + | <ol> | |
| 34 | + | <li> | |
| 35 | + | <span aria-current="page">not found</span> | |
| 36 | + | </li> | |
| 37 | + | </ol> | |
| 38 | + | </nav> | |
| 39 | + | <div class="hdr-acts"> | |
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |
| 42 | + | </span> | |
| 43 | + | <span class="search-label">Go to…</span> | |
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |
| 45 | + | </button> | |
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 47 | + | </div> | |
| 48 | + | </div> | |
| 49 | + | </header> | |
| 50 | + | <div class="work"> | |
| 51 | + | <main id="main" tabindex="-1"> | |
| 52 | + | <section class="state" aria-labelledby="state-title"> | |
| 53 | + | <p class="eyebrow">404</p> | |
| 54 | + | <h1 id="state-title">not found</h1> | |
| 55 | + | <p class="dim">no such page, ref or path.</p> | |
| 56 | + | <p class="dim">check the URL, or start from the repo list.</p> | |
| 57 | + | <p class="state-back"> | |
| 58 | + | <a href="/">home</a> | |
| 59 | + | </p> | |
| 60 | + | </section> | |
| 61 | + | </main> | |
| 62 | + | </div> | |
| 63 | + | <footer class="foot"> | |
| 64 | + | <span>ermite devel</span> | |
| 65 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 66 | + | <span class="foot-keys"> | |
| 67 | + | <kbd>?</kbd> keyboard shortcuts</span> | |
| 68 | + | </footer> | |
| 69 | + | <div class="hint" role="status" aria-live="polite"> | |
| 70 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 71 | + | </span> | |
| 72 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 73 | + | </span> | |
| 74 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 75 | + | </span> | |
| 76 | + | </div> | |
| 77 | + | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 78 | + | <div class="panel-head"> | |
| 79 | + | <h2>Keyboard shortcuts</h2> | |
| 80 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 81 | + | <kbd>esc</kbd> close</button> | |
| 82 | + | </div> | |
| 83 | + | <dl class="keys"> | |
| 84 | + | <div> | |
| 85 | + | <dt> | |
| 86 | + | <kbd>ctrl k</kbd> | |
| 87 | + | <kbd>:</kbd> | |
| 88 | + | </dt> | |
| 89 | + | <dd>go to a section, branch, tag, file or repository</dd> | |
| 90 | + | </div> | |
| 91 | + | <div> | |
| 92 | + | <dt> | |
| 93 | + | <kbd>?</kbd> | |
| 94 | + | </dt> | |
| 95 | + | <dd>this list</dd> | |
| 96 | + | </div> | |
| 97 | + | <div> | |
| 98 | + | <dt> | |
| 99 | + | <kbd>j</kbd> | |
| 100 | + | <kbd>k</kbd> | |
| 101 | + | </dt> | |
| 102 | + | <dd>move the row cursor down / up</dd> | |
| 103 | + | </div> | |
| 104 | + | <div> | |
| 105 | + | <dt> | |
| 106 | + | <kbd>enter</kbd> | |
| 107 | + | </dt> | |
| 108 | + | <dd>open the row under the cursor</dd> | |
| 109 | + | </div> | |
| 110 | + | <div> | |
| 111 | + | <dt> | |
| 112 | + | <kbd>g g</kbd> | |
| 113 | + | <kbd>G</kbd> | |
| 114 | + | </dt> | |
| 115 | + | <dd>first / last row</dd> | |
| 116 | + | </div> | |
| 117 | + | <div> | |
| 118 | + | <dt> | |
| 119 | + | <kbd>/</kbd> | |
| 120 | + | </dt> | |
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 122 | + | </div> | |
| 123 | + | <div> | |
| 124 | + | <dt> | |
| 125 | + | <kbd>b</kbd> | |
| 126 | + | </dt> | |
| 127 | + | <dd>switch branch or tag</dd> | |
| 128 | + | </div> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>s</kbd> | |
| 132 | + | <kbd>t</kbd> | |
| 133 | + | <kbd>c</kbd> | |
| 134 | + | <kbd>r</kbd> | |
| 135 | + | <kbd>m</kbd> | |
| 136 | + | </dt> | |
| 137 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 138 | + | </div> | |
| 139 | + | <div> | |
| 140 | + | <dt> | |
| 141 | + | <kbd>g h</kbd> | |
| 142 | + | </dt> | |
| 143 | + | <dd>home</dd> | |
| 144 | + | </div> | |
| 145 | + | <div> | |
| 146 | + | <dt> | |
| 147 | + | <kbd>y</kbd> | |
| 148 | + | </dt> | |
| 149 | + | <dd>copy a permalink pinned to the commit</dd> | |
| 150 | + | </div> | |
| 151 | + | <div> | |
| 152 | + | <dt> | |
| 153 | + | <kbd>d</kbd> | |
| 154 | + | <kbd>u</kbd> | |
| 155 | + | <kbd>x</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>diff: split / unified / collapse or expand all</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>n</kbd> | |
| 162 | + | </dt> | |
| 163 | + | <dd>merge request: next note in the diff</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt> | |
| 167 | + | <kbd>esc</kbd> | |
| 168 | + | </dt> | |
| 169 | + | <dd>close a dialog, leave a field</dd> | |
| 170 | + | </div> | |
| 171 | + | </dl> | |
| 172 | + | </dialog> | |
| 173 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 174 | + | <div class="panel-head"> | |
| 175 | + | <label for="cmd-q" class="sr">go to</label> | |
| 176 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 177 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </button> | |
| 180 | + | </div> | |
| 181 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 182 | + | <li data-k="go home" data-cursor> | |
| 183 | + | <a href="/" tabindex="-1"> | |
| 184 | + | <span class="lbl">home</span> | |
| 185 | + | <span class="k">go</span> | |
| 186 | + | </a> | |
| 187 | + | </li> | |
| 188 | + | </ul> | |
| 189 | + | <p class="panel-foot"> | |
| 190 | + | <span> | |
| 191 | + | <kbd>↑</kbd> | |
| 192 | + | <kbd>↓</kbd> move</span> | |
| 193 | + | <span> | |
| 194 | + | <kbd>enter</kbd> open</span> | |
| 195 | + | <span> | |
| 196 | + | <kbd>esc</kbd> close</span> | |
| 197 | + | </p> | |
| 198 | + | </dialog> | |
| 199 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 200 | + | k-k from:body -> ermite.move(-1); | |
| 201 | + | k-top from:body -> ermite.edge(false); | |
| 202 | + | k-bottom from:body -> ermite.edge(true); | |
| 203 | + | k-open from:body -> ermite.open(); | |
| 204 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 205 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 206 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 207 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 208 | + | k-filter from:body -> ermite.focusFilter(); | |
| 209 | + | k-gh from:body -> ermite.go('.brand'); | |
| 210 | + | k-s from:body -> ermite.tab('s'); | |
| 211 | + | k-t from:body -> ermite.tab('t'); | |
| 212 | + | k-c from:body -> ermite.tab('c'); | |
| 213 | + | k-r from:body -> ermite.tab('r'); | |
| 214 | + | k-m from:body -> ermite.tab('m'); | |
| 215 | + | k-next from:body -> ermite.nextNote(); | |
| 216 | + | k-split from:body -> ermite.layout('split'); | |
| 217 | + | k-unified from:body -> ermite.layout('unified'); | |
| 218 | + | k-collapse from:body -> ermite.collapse(); | |
| 219 | + | k-permalink from:body -> ermite.permalink()"> | |
| 220 | + | </div> | |
| 221 | + | </body> | |
| 222 | + | </html> | |
A internal/app/testdata/web/not-found.html +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>404 · example.test</title> | |
| 8 | + | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | + | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 12 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 13 | + | </script> | |
| 14 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 15 | + | </script> | |
| 16 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 17 | + | </script> | |
| 18 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 19 | + | </script> | |
| 20 | + | <script src="/-/static/js/files.HASH.js" defer> | |
| 21 | + | </script> | |
| 22 | + | </head> | |
| 23 | + | <body data-ref="" data-sha="" data-sel="" data-nav=""> | |
| 24 | + | <a class="skip" href="#main">skip to content</a> | |
| 25 | + | <header class="hdr"> | |
| 26 | + | <div class="hdr-top"> | |
| 27 | + | <a class="brand" href="/" data-g="h" title="home (g h)"> | |
| 28 | + | <span class="brand-mark" aria-hidden="true"> | |
| 29 | + | </span> | |
| 30 | + | <span class="brand-name">example.test</span> | |
| 31 | + | </a> | |
| 32 | + | <nav class="crumbs" aria-label="Path"> | |
| 33 | + | <ol> | |
| 34 | + | <li> | |
| 35 | + | <span aria-current="page">not found</span> | |
| 36 | + | </li> | |
| 37 | + | </ol> | |
| 38 | + | </nav> | |
| 39 | + | <div class="hdr-acts"> | |
| 40 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 41 | + | <span class="ico i-search" aria-hidden="true"> | |
| 42 | + | </span> | |
| 43 | + | <span class="search-label">Go to…</span> | |
| 44 | + | <kbd aria-hidden="true">ctrl k</kbd> | |
| 45 | + | </button> | |
| 46 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 47 | + | </div> | |
| 48 | + | </div> | |
| 49 | + | </header> | |
| 50 | + | <div class="work"> | |
| 51 | + | <main id="main" tabindex="-1"> | |
| 52 | + | <section class="state" aria-labelledby="state-title"> | |
| 53 | + | <p class="eyebrow">404</p> | |
| 54 | + | <h1 id="state-title">not found</h1> | |
| 55 | + | <p class="dim">no such page, ref or path.</p> | |
| 56 | + | <p class="dim">check the URL, or start from the repo list.</p> | |
| 57 | + | <p class="state-back"> | |
| 58 | + | <a href="/">home</a> | |
| 59 | + | </p> | |
| 60 | + | </section> | |
| 61 | + | </main> | |
| 62 | + | </div> | |
| 63 | + | <footer class="foot"> | |
| 64 | + | <span>ermite devel</span> | |
| 65 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 66 | + | <span class="foot-keys"> | |
| 67 | + | <kbd>?</kbd> keyboard shortcuts</span> | |
| 68 | + | </footer> | |
| 69 | + | <div class="hint" role="status" aria-live="polite"> | |
| 70 | + | <span class="hint-nav" :text="document.body.dataset.nav || ''"> | |
| 71 | + | </span> | |
| 72 | + | <span class="hint-sel" :text="document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''"> | |
| 73 | + | </span> | |
| 74 | + | <span class="hint-flash" :text="document.body.dataset.flash || ''"> | |
| 75 | + | </span> | |
| 76 | + | </div> | |
| 77 | + | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 78 | + | <div class="panel-head"> | |
| 79 | + | <h2>Keyboard shortcuts</h2> | |
| 80 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 81 | + | <kbd>esc</kbd> close</button> | |
| 82 | + | </div> | |
| 83 | + | <dl class="keys"> | |
| 84 | + | <div> | |
| 85 | + | <dt> | |
| 86 | + | <kbd>ctrl k</kbd> | |
| 87 | + | <kbd>:</kbd> | |
| 88 | + | </dt> | |
| 89 | + | <dd>go to a section, branch, tag, file or repository</dd> | |
| 90 | + | </div> | |
| 91 | + | <div> | |
| 92 | + | <dt> | |
| 93 | + | <kbd>?</kbd> | |
| 94 | + | </dt> | |
| 95 | + | <dd>this list</dd> | |
| 96 | + | </div> | |
| 97 | + | <div> | |
| 98 | + | <dt> | |
| 99 | + | <kbd>j</kbd> | |
| 100 | + | <kbd>k</kbd> | |
| 101 | + | </dt> | |
| 102 | + | <dd>move the row cursor down / up</dd> | |
| 103 | + | </div> | |
| 104 | + | <div> | |
| 105 | + | <dt> | |
| 106 | + | <kbd>enter</kbd> | |
| 107 | + | </dt> | |
| 108 | + | <dd>open the row under the cursor</dd> | |
| 109 | + | </div> | |
| 110 | + | <div> | |
| 111 | + | <dt> | |
| 112 | + | <kbd>g g</kbd> | |
| 113 | + | <kbd>G</kbd> | |
| 114 | + | </dt> | |
| 115 | + | <dd>first / last row</dd> | |
| 116 | + | </div> | |
| 117 | + | <div> | |
| 118 | + | <dt> | |
| 119 | + | <kbd>/</kbd> | |
| 120 | + | </dt> | |
| 121 | + | <dd>filter the list; on Files, find a file in the repository</dd> | |
| 122 | + | </div> | |
| 123 | + | <div> | |
| 124 | + | <dt> | |
| 125 | + | <kbd>b</kbd> | |
| 126 | + | </dt> | |
| 127 | + | <dd>switch branch or tag</dd> | |
| 128 | + | </div> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>s</kbd> | |
| 132 | + | <kbd>t</kbd> | |
| 133 | + | <kbd>c</kbd> | |
| 134 | + | <kbd>r</kbd> | |
| 135 | + | <kbd>m</kbd> | |
| 136 | + | </dt> | |
| 137 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 138 | + | </div> | |
| 139 | + | <div> | |
| 140 | + | <dt> | |
| 141 | + | <kbd>g h</kbd> | |
| 142 | + | </dt> | |
| 143 | + | <dd>home</dd> | |
| 144 | + | </div> | |
| 145 | + | <div> | |
| 146 | + | <dt> | |
| 147 | + | <kbd>y</kbd> | |
| 148 | + | </dt> | |
| 149 | + | <dd>copy a permalink pinned to the commit</dd> | |
| 150 | + | </div> | |
| 151 | + | <div> | |
| 152 | + | <dt> | |
| 153 | + | <kbd>d</kbd> | |
| 154 | + | <kbd>u</kbd> | |
| 155 | + | <kbd>x</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>diff: split / unified / collapse or expand all</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>n</kbd> | |
| 162 | + | </dt> | |
| 163 | + | <dd>merge request: next note in the diff</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt> | |
| 167 | + | <kbd>esc</kbd> | |
| 168 | + | </dt> | |
| 169 | + | <dd>close a dialog, leave a field</dd> | |
| 170 | + | </div> | |
| 171 | + | </dl> | |
| 172 | + | </dialog> | |
| 173 | + | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 174 | + | <div class="panel-head"> | |
| 175 | + | <label for="cmd-q" class="sr">go to</label> | |
| 176 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 177 | + | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 178 | + | <kbd>esc</kbd> | |
| 179 | + | </button> | |
| 180 | + | </div> | |
| 181 | + | <ul id="cmd-list" class="cmd-list" aria-label="Targets"> | |
| 182 | + | <li data-k="go home" data-cursor> | |
| 183 | + | <a href="/" tabindex="-1"> | |
| 184 | + | <span class="lbl">home</span> | |
| 185 | + | <span class="k">go</span> | |
| 186 | + | </a> | |
| 187 | + | </li> | |
| 188 | + | </ul> | |
| 189 | + | <p class="panel-foot"> | |
| 190 | + | <span> | |
| 191 | + | <kbd>↑</kbd> | |
| 192 | + | <kbd>↓</kbd> move</span> | |
| 193 | + | <span> | |
| 194 | + | <kbd>enter</kbd> open</span> | |
| 195 | + | <span> | |
| 196 | + | <kbd>esc</kbd> close</span> | |
| 197 | + | </p> | |
| 198 | + | </dialog> | |
| 199 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 200 | + | k-k from:body -> ermite.move(-1); | |
| 201 | + | k-top from:body -> ermite.edge(false); | |
| 202 | + | k-bottom from:body -> ermite.edge(true); | |
| 203 | + | k-open from:body -> ermite.open(); | |
| 204 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 205 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 206 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 207 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 208 | + | k-filter from:body -> ermite.focusFilter(); | |
| 209 | + | k-gh from:body -> ermite.go('.brand'); | |
| 210 | + | k-s from:body -> ermite.tab('s'); | |
| 211 | + | k-t from:body -> ermite.tab('t'); | |
| 212 | + | k-c from:body -> ermite.tab('c'); | |
| 213 | + | k-r from:body -> ermite.tab('r'); | |
| 214 | + | k-m from:body -> ermite.tab('m'); | |
| 215 | + | k-next from:body -> ermite.nextNote(); | |
| 216 | + | k-split from:body -> ermite.layout('split'); | |
| 217 | + | k-unified from:body -> ermite.layout('unified'); | |
| 218 | + | k-collapse from:body -> ermite.collapse(); | |
| 219 | + | k-permalink from:body -> ermite.permalink()"> | |
| 220 | + | </div> | |
| 221 | + | </body> | |
| 222 | + | </html> | |
R cmd/ermite/testdata/web/refs.html → internal/app/testdata/web/refs.html +31 -75 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -39,19 +40,19 @@ | |||
| 39 | 40 | </ol> | |
| 40 | 41 | </nav> | |
| 41 | 42 | <div class="hdr-acts"> | |
| 42 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 43 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 43 | 44 | <span class="ico i-branch" aria-hidden="true"> | |
| 44 | 45 | </span> | |
| 45 | 46 | <span class="ref-name">main</span> | |
| 46 | 47 | <span class="sha">SHA7</span> | |
| 47 | - | </button> | |
| 48 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 48 | + | </a> | |
| 49 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 49 | 50 | <span class="ico i-search" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="search-label">Go to…</span> | |
| 52 | 53 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 53 | 54 | </button> | |
| 54 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 55 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 55 | 56 | </div> | |
| 56 | 57 | </div> | |
| 57 | 58 | <nav class="tabs" aria-label="Sections"> | |
| @@ -83,8 +84,7 @@ | |||
| 83 | 84 | <main id="main" tabindex="-1"> | |
| 84 | 85 | <div class="filter"> | |
| 85 | 86 | <label for="ref-filter" class="sr">filter</label> | |
| 86 | - | <input id="ref-filter" type="search" data-filter placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 87 | - | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"> | |
| 87 | + | <input id="ref-filter" type="search" data-filter="#ref-rows > tr" placeholder="Filter branches and tags…" autocomplete="off" hx-on:input="ermite.filter(this)"> | |
| 88 | 88 | </div> | |
| 89 | 89 | <table class="list refs" aria-label="Branches and tags"> | |
| 90 | 90 | <thead> | |
| @@ -194,6 +194,8 @@ | |||
| 194 | 194 | </span> | |
| 195 | 195 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=refs" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 196 | 196 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 197 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 198 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 197 | 199 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 198 | 200 | gap -> ermiteLive.reload()"> | |
| 199 | 201 | </span> | |
| @@ -297,7 +299,7 @@ | |||
| 297 | 299 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 298 | 300 | <div class="panel-head"> | |
| 299 | 301 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 300 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 302 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 301 | 303 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 302 | 304 | <kbd>esc</kbd> | |
| 303 | 305 | </button> | |
| @@ -336,13 +338,7 @@ | |||
| 336 | 338 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 337 | 339 | <div class="panel-head"> | |
| 338 | 340 | <label for="cmd-q" class="sr">go to</label> | |
| 339 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 340 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 341 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 342 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 343 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 344 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 345 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 341 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 346 | 342 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 347 | 343 | <kbd>esc</kbd> | |
| 348 | 344 | </button> | |
| @@ -425,67 +421,27 @@ | |||
| 425 | 421 | <kbd>esc</kbd> close</span> | |
| 426 | 422 | </p> | |
| 427 | 423 | </dialog> | |
| 428 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 429 | - | if (q('dialog[open]').count) return; | |
| 430 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 431 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 432 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 433 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 434 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 435 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 436 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 437 | - | })(1); | |
| 438 | - | k-k from:body -> (async (d) => { | |
| 439 | - | if (q('dialog[open]').count) return; | |
| 440 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 441 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 442 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 443 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 444 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 445 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 446 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 447 | - | })(-1); | |
| 448 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 449 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 450 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 451 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 452 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 453 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 454 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 455 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 456 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 457 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 458 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 459 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 460 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 461 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 462 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 463 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 464 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 465 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 466 | - | k-next from:body -> { | |
| 467 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 468 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 469 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 470 | - | }; | |
| 471 | - | k-split from:body -> { | |
| 472 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 473 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 474 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 475 | - | }; | |
| 476 | - | k-unified from:body -> { | |
| 477 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 478 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 479 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 480 | - | }; | |
| 481 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 482 | - | k-permalink from:body -> { | |
| 483 | - | let b = document.body.dataset; | |
| 484 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 485 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 486 | - | navigator.clipboard?.writeText(location.href); | |
| 487 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 488 | - | }"> | |
| 424 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 425 | + | k-k from:body -> ermite.move(-1); | |
| 426 | + | k-top from:body -> ermite.edge(false); | |
| 427 | + | k-bottom from:body -> ermite.edge(true); | |
| 428 | + | k-open from:body -> ermite.open(); | |
| 429 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 430 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 431 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 432 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 433 | + | k-filter from:body -> ermite.focusFilter(); | |
| 434 | + | k-gh from:body -> ermite.go('.brand'); | |
| 435 | + | k-s from:body -> ermite.tab('s'); | |
| 436 | + | k-t from:body -> ermite.tab('t'); | |
| 437 | + | k-c from:body -> ermite.tab('c'); | |
| 438 | + | k-r from:body -> ermite.tab('r'); | |
| 439 | + | k-m from:body -> ermite.tab('m'); | |
| 440 | + | k-next from:body -> ermite.nextNote(); | |
| 441 | + | k-split from:body -> ermite.layout('split'); | |
| 442 | + | k-unified from:body -> ermite.layout('unified'); | |
| 443 | + | k-collapse from:body -> ermite.collapse(); | |
| 444 | + | k-permalink from:body -> ermite.permalink()"> | |
| 489 | 445 | </div> | |
| 490 | 446 | </body> | |
| 491 | 447 | </html> | |
R cmd/ermite/testdata/web/summary.html → internal/app/testdata/web/summary.html +32 -75 renamed, 62% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -36,19 +37,19 @@ | |||
| 36 | 37 | </ol> | |
| 37 | 38 | </nav> | |
| 38 | 39 | <div class="hdr-acts"> | |
| 39 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 40 | + | <a class="btn ref" href="/demo/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 40 | 41 | <span class="ico i-branch" aria-hidden="true"> | |
| 41 | 42 | </span> | |
| 42 | 43 | <span class="ref-name">main</span> | |
| 43 | 44 | <span class="sha">SHA7</span> | |
| 44 | - | </button> | |
| 45 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 45 | + | </a> | |
| 46 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 46 | 47 | <span class="ico i-search" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="search-label">Go to…</span> | |
| 49 | 50 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 50 | 51 | </button> | |
| 51 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 52 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 52 | 53 | </div> | |
| 53 | 54 | </div> | |
| 54 | 55 | <nav class="tabs" aria-label="Sections"> | |
| @@ -85,7 +86,7 @@ | |||
| 85 | 86 | <h2 class="card-h" id="readme-h"> | |
| 86 | 87 | <span class="ico i-file" aria-hidden="true"> | |
| 87 | 88 | </span> README.md</h2> | |
| 88 | - | <article class="prose" aria-label="README.md"> | |
| 89 | + | <article class="prose" aria-label="README.md" hx-ignore> | |
| 89 | 90 | <h1 id="md-demo">Demo</h1> | |
| 90 | 91 | <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p> | |
| 91 | 92 | <h2 id="md-install">Install</h2> | |
| @@ -118,7 +119,7 @@ | |||
| 118 | 119 | <div class="clone-line"> | |
| 119 | 120 | <span class="mute" aria-hidden="true">$ </span> | |
| 120 | 121 | <code>git clone http://example.test/demo.git</code> | |
| 121 | - | <button type="button" class="btn copy" data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = 'Copied'; await timeout('1500ms'); this.textContent = this.dataset.label">Copy</button> | |
| 122 | + | <button type="button" class="btn copy" data-js data-label="Copy" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="ermite.copy(this)">Copy</button> | |
| 122 | 123 | </div> | |
| 123 | 124 | </div> | |
| 124 | 125 | </section> | |
| @@ -212,6 +213,8 @@ | |||
| 212 | 213 | </span> | |
| 213 | 214 | <span class="hint-live" data-state="connecting" hx-sse:connect="/-/events?topic=repo%3Ademo&view=summary" hx-swap="none" :text="data.state === 'offline' ? 'live updates paused' : ''" hx-on="htmx:sse:after:connection -> data.state = 'live'; | |
| 214 | 215 | htmx:sse:close, htmx:sse:error -> data.state = 'offline'; | |
| 216 | + | htmx:sse:error -> ermiteLive.error(event.detail.status); | |
| 217 | + | htmx:sse:before:connection -> ermiteLive.gate(event); | |
| 215 | 218 | home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data); | |
| 216 | 219 | gap -> ermiteLive.reload()"> | |
| 217 | 220 | </span> | |
| @@ -315,7 +318,7 @@ | |||
| 315 | 318 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 316 | 319 | <div class="panel-head"> | |
| 317 | 320 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 318 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 321 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 319 | 322 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 320 | 323 | <kbd>esc</kbd> | |
| 321 | 324 | </button> | |
| @@ -354,13 +357,7 @@ | |||
| 354 | 357 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 355 | 358 | <div class="panel-head"> | |
| 356 | 359 | <label for="cmd-q" class="sr">go to</label> | |
| 357 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 358 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 359 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 360 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 361 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 362 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 363 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 360 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 364 | 361 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 365 | 362 | <kbd>esc</kbd> | |
| 366 | 363 | </button> | |
| @@ -443,67 +440,27 @@ | |||
| 443 | 440 | <kbd>esc</kbd> close</span> | |
| 444 | 441 | </p> | |
| 445 | 442 | </dialog> | |
| 446 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 447 | - | if (q('dialog[open]').count) return; | |
| 448 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 449 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 450 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 451 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 452 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 453 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 454 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 455 | - | })(1); | |
| 456 | - | k-k from:body -> (async (d) => { | |
| 457 | - | if (q('dialog[open]').count) return; | |
| 458 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 459 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 460 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 461 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 462 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 463 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 464 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 465 | - | })(-1); | |
| 466 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 467 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 468 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 469 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 470 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 471 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 472 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 473 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 474 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 475 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 476 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 477 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 478 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 479 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 480 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 481 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 482 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 483 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 484 | - | k-next from:body -> { | |
| 485 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 486 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 487 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 488 | - | }; | |
| 489 | - | k-split from:body -> { | |
| 490 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 491 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 492 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 493 | - | }; | |
| 494 | - | k-unified from:body -> { | |
| 495 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 496 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 497 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 498 | - | }; | |
| 499 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 500 | - | k-permalink from:body -> { | |
| 501 | - | let b = document.body.dataset; | |
| 502 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 503 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 504 | - | navigator.clipboard?.writeText(location.href); | |
| 505 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 506 | - | }"> | |
| 443 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 444 | + | k-k from:body -> ermite.move(-1); | |
| 445 | + | k-top from:body -> ermite.edge(false); | |
| 446 | + | k-bottom from:body -> ermite.edge(true); | |
| 447 | + | k-open from:body -> ermite.open(); | |
| 448 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 449 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 450 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 451 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 452 | + | k-filter from:body -> ermite.focusFilter(); | |
| 453 | + | k-gh from:body -> ermite.go('.brand'); | |
| 454 | + | k-s from:body -> ermite.tab('s'); | |
| 455 | + | k-t from:body -> ermite.tab('t'); | |
| 456 | + | k-c from:body -> ermite.tab('c'); | |
| 457 | + | k-r from:body -> ermite.tab('r'); | |
| 458 | + | k-m from:body -> ermite.tab('m'); | |
| 459 | + | k-next from:body -> ermite.nextNote(); | |
| 460 | + | k-split from:body -> ermite.layout('split'); | |
| 461 | + | k-unified from:body -> ermite.layout('unified'); | |
| 462 | + | k-collapse from:body -> ermite.collapse(); | |
| 463 | + | k-permalink from:body -> ermite.permalink()"> | |
| 507 | 464 | </div> | |
| 508 | 465 | </body> | |
| 509 | 466 | </html> | |
R cmd/ermite/testdata/web/tree-nested.html → internal/app/testdata/web/tree-nested.html +28 -73 renamed, 58% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -48,19 +49,19 @@ | |||
| 48 | 49 | </ol> | |
| 49 | 50 | </nav> | |
| 50 | 51 | <div class="hdr-acts"> | |
| 51 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 52 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 52 | 53 | <span class="ico i-branch" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="ref-name">main</span> | |
| 55 | 56 | <span class="sha">SHA7</span> | |
| 56 | - | </button> | |
| 57 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 57 | + | </a> | |
| 58 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 58 | 59 | <span class="ico i-search" aria-hidden="true"> | |
| 59 | 60 | </span> | |
| 60 | 61 | <span class="search-label">Go to…</span> | |
| 61 | 62 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 62 | 63 | </button> | |
| 63 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 64 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 64 | 65 | </div> | |
| 65 | 66 | </div> | |
| 66 | 67 | <nav class="tabs" aria-label="Sections"> | |
| @@ -250,7 +251,7 @@ | |||
| 250 | 251 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 251 | 252 | <div class="panel-head"> | |
| 252 | 253 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 253 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 254 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 254 | 255 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 255 | 256 | <kbd>esc</kbd> | |
| 256 | 257 | </button> | |
| @@ -285,13 +286,7 @@ | |||
| 285 | 286 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 286 | 287 | <div class="panel-head"> | |
| 287 | 288 | <label for="cmd-q" class="sr">go to</label> | |
| 288 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 289 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 290 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 291 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 292 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 293 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 294 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 289 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 295 | 290 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 296 | 291 | <kbd>esc</kbd> | |
| 297 | 292 | </button> | |
| @@ -362,67 +357,27 @@ | |||
| 362 | 357 | <kbd>esc</kbd> close</span> | |
| 363 | 358 | </p> | |
| 364 | 359 | </dialog> | |
| 365 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 366 | - | if (q('dialog[open]').count) return; | |
| 367 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 368 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 369 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 370 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 371 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 372 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 373 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 374 | - | })(1); | |
| 375 | - | k-k from:body -> (async (d) => { | |
| 376 | - | if (q('dialog[open]').count) return; | |
| 377 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 378 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 379 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 380 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 381 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 382 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 383 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 384 | - | })(-1); | |
| 385 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 386 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 387 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 388 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 389 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 390 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 391 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 392 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 393 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 394 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 395 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 396 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 397 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 398 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 399 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 400 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 401 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 402 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 403 | - | k-next from:body -> { | |
| 404 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 405 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 406 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 407 | - | }; | |
| 408 | - | k-split from:body -> { | |
| 409 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 410 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 411 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 412 | - | }; | |
| 413 | - | k-unified from:body -> { | |
| 414 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 415 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 416 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 417 | - | }; | |
| 418 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 419 | - | k-permalink from:body -> { | |
| 420 | - | let b = document.body.dataset; | |
| 421 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 422 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 423 | - | navigator.clipboard?.writeText(location.href); | |
| 424 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 425 | - | }"> | |
| 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 361 | + | k-k from:body -> ermite.move(-1); | |
| 362 | + | k-top from:body -> ermite.edge(false); | |
| 363 | + | k-bottom from:body -> ermite.edge(true); | |
| 364 | + | k-open from:body -> ermite.open(); | |
| 365 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 366 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 367 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 368 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 369 | + | k-filter from:body -> ermite.focusFilter(); | |
| 370 | + | k-gh from:body -> ermite.go('.brand'); | |
| 371 | + | k-s from:body -> ermite.tab('s'); | |
| 372 | + | k-t from:body -> ermite.tab('t'); | |
| 373 | + | k-c from:body -> ermite.tab('c'); | |
| 374 | + | k-r from:body -> ermite.tab('r'); | |
| 375 | + | k-m from:body -> ermite.tab('m'); | |
| 376 | + | k-next from:body -> ermite.nextNote(); | |
| 377 | + | k-split from:body -> ermite.layout('split'); | |
| 378 | + | k-unified from:body -> ermite.layout('unified'); | |
| 379 | + | k-collapse from:body -> ermite.collapse(); | |
| 380 | + | k-permalink from:body -> ermite.permalink()"> | |
| 426 | 381 | </div> | |
| 427 | 382 | </body> | |
| 428 | 383 | </html> | |
R cmd/ermite/testdata/web/tree-root.html → internal/app/testdata/web/tree-root.html +29 -74 renamed, 69% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -42,19 +43,19 @@ | |||
| 42 | 43 | </ol> | |
| 43 | 44 | </nav> | |
| 44 | 45 | <div class="hdr-acts"> | |
| 45 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 46 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 46 | 47 | <span class="ico i-branch" aria-hidden="true"> | |
| 47 | 48 | </span> | |
| 48 | 49 | <span class="ref-name">main</span> | |
| 49 | 50 | <span class="sha">SHA7</span> | |
| 50 | - | </button> | |
| 51 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 51 | + | </a> | |
| 52 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 52 | 53 | <span class="ico i-search" aria-hidden="true"> | |
| 53 | 54 | </span> | |
| 54 | 55 | <span class="search-label">Go to…</span> | |
| 55 | 56 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 56 | 57 | </button> | |
| 57 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 58 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 58 | 59 | </div> | |
| 59 | 60 | </div> | |
| 60 | 61 | <nav class="tabs" aria-label="Sections"> | |
| @@ -356,7 +357,7 @@ | |||
| 356 | 357 | <h2 class="card-h" id="readme-h"> | |
| 357 | 358 | <span class="ico i-file" aria-hidden="true"> | |
| 358 | 359 | </span> README.md</h2> | |
| 359 | - | <article class="prose" aria-label="README.md"> | |
| 360 | + | <article class="prose" aria-label="README.md" hx-ignore> | |
| 360 | 361 | <h1 id="md-files">Files</h1> | |
| 361 | 362 | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | |
| 362 | 363 | </article> | |
| @@ -477,7 +478,7 @@ | |||
| 477 | 478 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 478 | 479 | <div class="panel-head"> | |
| 479 | 480 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 480 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 481 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 481 | 482 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 482 | 483 | <kbd>esc</kbd> | |
| 483 | 484 | </button> | |
| @@ -512,13 +513,7 @@ | |||
| 512 | 513 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 513 | 514 | <div class="panel-head"> | |
| 514 | 515 | <label for="cmd-q" class="sr">go to</label> | |
| 515 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 516 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 517 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 518 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 519 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 520 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 521 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 516 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 522 | 517 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 523 | 518 | <kbd>esc</kbd> | |
| 524 | 519 | </button> | |
| @@ -589,67 +584,27 @@ | |||
| 589 | 584 | <kbd>esc</kbd> close</span> | |
| 590 | 585 | </p> | |
| 591 | 586 | </dialog> | |
| 592 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 593 | - | if (q('dialog[open]').count) return; | |
| 594 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 595 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 596 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 597 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 598 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 599 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 600 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 601 | - | })(1); | |
| 602 | - | k-k from:body -> (async (d) => { | |
| 603 | - | if (q('dialog[open]').count) return; | |
| 604 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 605 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 606 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 607 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 608 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 609 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 610 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 611 | - | })(-1); | |
| 612 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 613 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 614 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 615 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 616 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 617 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 618 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 619 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 620 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 621 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 622 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 623 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 624 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 625 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 626 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 627 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 628 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 629 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 630 | - | k-next from:body -> { | |
| 631 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 632 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 633 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 634 | - | }; | |
| 635 | - | k-split from:body -> { | |
| 636 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 637 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 638 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 639 | - | }; | |
| 640 | - | k-unified from:body -> { | |
| 641 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 642 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 643 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 644 | - | }; | |
| 645 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 646 | - | k-permalink from:body -> { | |
| 647 | - | let b = document.body.dataset; | |
| 648 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 649 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 650 | - | navigator.clipboard?.writeText(location.href); | |
| 651 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 652 | - | }"> | |
| 587 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 588 | + | k-k from:body -> ermite.move(-1); | |
| 589 | + | k-top from:body -> ermite.edge(false); | |
| 590 | + | k-bottom from:body -> ermite.edge(true); | |
| 591 | + | k-open from:body -> ermite.open(); | |
| 592 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 593 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 594 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 595 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 596 | + | k-filter from:body -> ermite.focusFilter(); | |
| 597 | + | k-gh from:body -> ermite.go('.brand'); | |
| 598 | + | k-s from:body -> ermite.tab('s'); | |
| 599 | + | k-t from:body -> ermite.tab('t'); | |
| 600 | + | k-c from:body -> ermite.tab('c'); | |
| 601 | + | k-r from:body -> ermite.tab('r'); | |
| 602 | + | k-m from:body -> ermite.tab('m'); | |
| 603 | + | k-next from:body -> ermite.nextNote(); | |
| 604 | + | k-split from:body -> ermite.layout('split'); | |
| 605 | + | k-unified from:body -> ermite.layout('unified'); | |
| 606 | + | k-collapse from:body -> ermite.collapse(); | |
| 607 | + | k-permalink from:body -> ermite.permalink()"> | |
| 653 | 608 | </div> | |
| 654 | 609 | </body> | |
| 655 | 610 | </html> | |
R cmd/ermite/testdata/web/tree-slash-ref.html → internal/app/testdata/web/tree-slash-ref.html +28 -73 renamed, 58% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">feature/x</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -247,7 +248,7 @@ | |||
| 247 | 248 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 248 | 249 | <div class="panel-head"> | |
| 249 | 250 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 250 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 251 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 251 | 252 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 252 | 253 | <kbd>esc</kbd> | |
| 253 | 254 | </button> | |
| @@ -282,13 +283,7 @@ | |||
| 282 | 283 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 283 | 284 | <div class="panel-head"> | |
| 284 | 285 | <label for="cmd-q" class="sr">go to</label> | |
| 285 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 286 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 287 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 288 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 289 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 290 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 291 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 286 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 292 | 287 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 293 | 288 | <kbd>esc</kbd> | |
| 294 | 289 | </button> | |
| @@ -359,67 +354,27 @@ | |||
| 359 | 354 | <kbd>esc</kbd> close</span> | |
| 360 | 355 | </p> | |
| 361 | 356 | </dialog> | |
| 362 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 363 | - | if (q('dialog[open]').count) return; | |
| 364 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 365 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 366 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 367 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 368 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 369 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 370 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 371 | - | })(1); | |
| 372 | - | k-k from:body -> (async (d) => { | |
| 373 | - | if (q('dialog[open]').count) return; | |
| 374 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 375 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 376 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 377 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 378 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 379 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 380 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 381 | - | })(-1); | |
| 382 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 383 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 384 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 385 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 386 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 387 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 388 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 389 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 390 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 391 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 392 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 393 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 394 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 395 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 396 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 397 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 398 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 399 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 400 | - | k-next from:body -> { | |
| 401 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 402 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 403 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 404 | - | }; | |
| 405 | - | k-split from:body -> { | |
| 406 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 407 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 408 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 409 | - | }; | |
| 410 | - | k-unified from:body -> { | |
| 411 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 412 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 413 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 414 | - | }; | |
| 415 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 416 | - | k-permalink from:body -> { | |
| 417 | - | let b = document.body.dataset; | |
| 418 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 419 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 420 | - | navigator.clipboard?.writeText(location.href); | |
| 421 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 422 | - | }"> | |
| 357 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 358 | + | k-k from:body -> ermite.move(-1); | |
| 359 | + | k-top from:body -> ermite.edge(false); | |
| 360 | + | k-bottom from:body -> ermite.edge(true); | |
| 361 | + | k-open from:body -> ermite.open(); | |
| 362 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 363 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 364 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 365 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 366 | + | k-filter from:body -> ermite.focusFilter(); | |
| 367 | + | k-gh from:body -> ermite.go('.brand'); | |
| 368 | + | k-s from:body -> ermite.tab('s'); | |
| 369 | + | k-t from:body -> ermite.tab('t'); | |
| 370 | + | k-c from:body -> ermite.tab('c'); | |
| 371 | + | k-r from:body -> ermite.tab('r'); | |
| 372 | + | k-m from:body -> ermite.tab('m'); | |
| 373 | + | k-next from:body -> ermite.nextNote(); | |
| 374 | + | k-split from:body -> ermite.layout('split'); | |
| 375 | + | k-unified from:body -> ermite.layout('unified'); | |
| 376 | + | k-collapse from:body -> ermite.collapse(); | |
| 377 | + | k-permalink from:body -> ermite.permalink()"> | |
| 423 | 378 | </div> | |
| 424 | 379 | </body> | |
| 425 | 380 | </html> | |
R cmd/ermite/testdata/web/tree-subdir.html → internal/app/testdata/web/tree-subdir.html +29 -74 renamed, 60% similar
| @@ -8,6 +8,7 @@ | |||
| 8 | 8 | <link rel="icon" type="image/svg+xml" href="/-/static/img/icon.HASH.svg"> | |
| 9 | 9 | <link rel="preload" href="/-/static/fonts/atkinson-next.HASH.woff2" as="font" type="font/woff2" crossorigin> | |
| 10 | 10 | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 11 | + | <meta name="htmx-config" content='{"history":false,"includeIndicatorCSS":false,"extensions":"sse, hx-live"}'> | |
| 11 | 12 | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 12 | 13 | </script> | |
| 13 | 14 | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| @@ -45,19 +46,19 @@ | |||
| 45 | 46 | </ol> | |
| 46 | 47 | </nav> | |
| 47 | 48 | <div class="hdr-acts"> | |
| 48 | - | <button type="button" class="btn ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch branch or tag (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))"> | |
| 49 | + | <a class="btn ref" href="/files/refs" data-fire="refs" aria-keyshortcuts="b" title="switch branch or tag (b)"> | |
| 49 | 50 | <span class="ico i-branch" aria-hidden="true"> | |
| 50 | 51 | </span> | |
| 51 | 52 | <span class="ref-name">main</span> | |
| 52 | 53 | <span class="sha">SHA7</span> | |
| 53 | - | </button> | |
| 54 | - | <button type="button" class="btn search" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 54 | + | </a> | |
| 55 | + | <button type="button" class="btn search" data-js aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts="Control+K :" title="go to a section, ref, file or repo (ctrl k or :)" data-fire="cmd"> | |
| 55 | 56 | <span class="ico i-search" aria-hidden="true"> | |
| 56 | 57 | </span> | |
| 57 | 58 | <span class="search-label">Go to…</span> | |
| 58 | 59 | <kbd aria-hidden="true">ctrl k</kbd> | |
| 59 | 60 | </button> | |
| 60 | - | <button type="button" class="btn icon" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-help'))">?</button> | |
| 61 | + | <button type="button" class="btn icon" data-js data-fire="help" aria-haspopup="dialog" aria-controls="panel-help" aria-keyshortcuts="?" aria-label="Keyboard shortcuts" title="keyboard shortcuts (?)">?</button> | |
| 61 | 62 | </div> | |
| 62 | 63 | </div> | |
| 63 | 64 | <nav class="tabs" aria-label="Sections"> | |
| @@ -151,7 +152,7 @@ | |||
| 151 | 152 | <h2 class="card-h" id="readme-h"> | |
| 152 | 153 | <span class="ico i-file" aria-hidden="true"> | |
| 153 | 154 | </span> README.md</h2> | |
| 154 | - | <article class="prose" aria-label="README.md"> | |
| 155 | + | <article class="prose" aria-label="README.md" hx-ignore> | |
| 155 | 156 | <h1 id="md-docs">Docs</h1> | |
| 156 | 157 | <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p> | |
| 157 | 158 | </article> | |
| @@ -272,7 +273,7 @@ | |||
| 272 | 273 | <dialog id="panel-refs" class="panel modal" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 273 | 274 | <div class="panel-head"> | |
| 274 | 275 | <label for="refs-filter" class="sr">filter branches and tags</label> | |
| 275 | - | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 276 | + | <input id="refs-filter" type="search" placeholder="Switch branch or tag…" autocomplete="off" data-pick="#panel-refs li" hx-on:input="ermite.filter(this)"> | |
| 276 | 277 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 277 | 278 | <kbd>esc</kbd> | |
| 278 | 279 | </button> | |
| @@ -307,13 +308,7 @@ | |||
| 307 | 308 | <dialog id="panel-cmd" class="panel modal" aria-label="Go to" hx-on:close="ermiteFinder.paletteClose()" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 308 | 309 | <div class="panel-head"> | |
| 309 | 310 | <label for="cmd-q" class="sr">go to</label> | |
| 310 | - | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 311 | - | q('#cmd-list > li[data-k]').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 312 | - | let first = q('#cmd-list > li[data-k]:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li'); ermiteFinder.palette(this)" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 313 | - | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 314 | - | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 315 | - | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 316 | - | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 311 | + | <input id="cmd-q" type="search" placeholder="Go to a section, branch, tag, file or repository…" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="ermite.cmdFilter(this); ermiteFinder.palette(this)" hx-on:keydown="ermite.cmdKey(event)"> | |
| 317 | 312 | <button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"> | |
| 318 | 313 | <kbd>esc</kbd> | |
| 319 | 314 | </button> | |
| @@ -384,67 +379,27 @@ | |||
| 384 | 379 | <kbd>esc</kbd> close</span> | |
| 385 | 380 | </p> | |
| 386 | 381 | </dialog> | |
| 387 | - | <div id="keys" hidden hx-on="k-j from:body -> (async (d) => { | |
| 388 | - | if (q('dialog[open]').count) return; | |
| 389 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 390 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 391 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 392 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 393 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 394 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 395 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 396 | - | })(1); | |
| 397 | - | k-k from:body -> (async (d) => { | |
| 398 | - | if (q('dialog[open]').count) return; | |
| 399 | - | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 400 | - | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 401 | - | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 402 | - | let j = Math.max(0, Math.min(rows.length - 1, i + d)), n = rows[j]; | |
| 403 | - | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }); | |
| 404 | - | let b = document.body.dataset, mark = (j + 1) + '/' + rows.length; b.nav = mark; | |
| 405 | - | await timeout('1500ms'); if (b.nav === mark) b.nav = '' | |
| 406 | - | })(-1); | |
| 407 | - | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 408 | - | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 409 | - | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 410 | - | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 411 | - | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].showModal(); | |
| 412 | - | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].showModal(), q('#refs-filter').arr()[0].focus()); | |
| 413 | - | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].showModal(); q('#cmd-q').arr()[0].focus(); ermiteFinder.load(); | |
| 414 | - | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 415 | - | k-gh from:body -> document.querySelector('.brand')?.click(); | |
| 416 | - | k-gt from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 417 | - | k-gc from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 418 | - | k-gr from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 419 | - | k-s from:body -> document.querySelector('.tabs [data-key=s]')?.click(); | |
| 420 | - | k-t from:body -> document.querySelector('.tabs [data-key=t]')?.click(); | |
| 421 | - | k-c from:body -> document.querySelector('.tabs [data-key=c]')?.click(); | |
| 422 | - | k-r from:body -> document.querySelector('.tabs [data-key=r]')?.click(); | |
| 423 | - | k-m from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 424 | - | k-gm from:body -> document.querySelector('.tabs [data-key=m]')?.click(); | |
| 425 | - | k-next from:body -> { | |
| 426 | - | let ns = q('#diffs .cmt').arr(); if (!ns.length) return; | |
| 427 | - | let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3); | |
| 428 | - | let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' }) | |
| 429 | - | }; | |
| 430 | - | k-split from:body -> { | |
| 431 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 432 | - | el.dataset.diff = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 433 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 434 | - | }; | |
| 435 | - | k-unified from:body -> { | |
| 436 | - | let el = q('[data-diff]').arr()[0]; if (!el) return; | |
| 437 | - | el.dataset.diff = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 438 | - | q('[data-file]').arr().filter(d => d.querySelector('.dt')).forEach(d => d.open = true) | |
| 439 | - | }; | |
| 440 | - | k-collapse from:body -> { let fs = q('[data-file]').arr(), open = !fs.some(d => d.open); fs.forEach(d => d.open = open) }; | |
| 441 | - | k-permalink from:body -> { | |
| 442 | - | let b = document.body.dataset; | |
| 443 | - | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 444 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 445 | - | navigator.clipboard?.writeText(location.href); | |
| 446 | - | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 447 | - | }"> | |
| 382 | + | <div id="keys" hidden hx-on="k-j from:body -> ermite.move(1); | |
| 383 | + | k-k from:body -> ermite.move(-1); | |
| 384 | + | k-top from:body -> ermite.edge(false); | |
| 385 | + | k-bottom from:body -> ermite.edge(true); | |
| 386 | + | k-open from:body -> ermite.open(); | |
| 387 | + | k-esc from:body -> ermite.closeDialogs(); | |
| 388 | + | k-help from:body -> ermite.dialog('panel-help'); | |
| 389 | + | k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter'); | |
| 390 | + | k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load(); | |
| 391 | + | k-filter from:body -> ermite.focusFilter(); | |
| 392 | + | k-gh from:body -> ermite.go('.brand'); | |
| 393 | + | k-s from:body -> ermite.tab('s'); | |
| 394 | + | k-t from:body -> ermite.tab('t'); | |
| 395 | + | k-c from:body -> ermite.tab('c'); | |
| 396 | + | k-r from:body -> ermite.tab('r'); | |
| 397 | + | k-m from:body -> ermite.tab('m'); | |
| 398 | + | k-next from:body -> ermite.nextNote(); | |
| 399 | + | k-split from:body -> ermite.layout('split'); | |
| 400 | + | k-unified from:body -> ermite.layout('unified'); | |
| 401 | + | k-collapse from:body -> ermite.collapse(); | |
| 402 | + | k-permalink from:body -> ermite.permalink()"> | |
| 448 | 403 | </div> | |
| 449 | 404 | </body> | |
| 450 | 405 | </html> | |
R cmd/ermite/web.go → internal/app/web.go +45 -44 renamed, 56% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -13,40 +13,35 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/zerowrap" | |
| 15 | 15 | ||
| 16 | - | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 17 | 16 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 18 | - | "bnema.dev/ermite/internal/adapters/rpc" | |
| 19 | 17 | "bnema.dev/ermite/internal/adapters/web" | |
| 20 | - | "bnema.dev/ermite/internal/app" | |
| 21 | 18 | "bnema.dev/ermite/internal/clientlimit" | |
| 22 | 19 | "bnema.dev/ermite/internal/config" | |
| 23 | 20 | "bnema.dev/ermite/internal/domain" | |
| 21 | + | "bnema.dev/ermite/internal/ports" | |
| 22 | + | "bnema.dev/ermite/internal/usecase" | |
| 24 | 23 | ) | |
| 25 | 24 | ||
| 26 | - | // newWeb builds the web pages on the API of this process: the generated | |
| 27 | - | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | |
| 25 | + | // newWeb builds the web pages on the use cases of this process, through the | |
| 26 | + | // ports (ADR 0020), like the API and Git over HTTP. | |
| 28 | 27 | // | |
| 29 | 28 | // The owner's session cookie turns a request into the owner's (web.Server | |
| 30 | - | // checks it with sessions); every other request is anonymous. | |
| 31 | - | func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 32 | - | hc := api.InProcessClient() | |
| 33 | - | srv, err := web.New(web.Config{ | |
| 34 | - | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| 35 | - | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | |
| 36 | - | MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL), | |
| 37 | - | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | |
| 29 | + | // checks it with sessions); every other request is anonymous. The web passes | |
| 30 | + | // the principal of the request to each use case call itself. | |
| 31 | + | func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 32 | + | return web.New(web.Config{ | |
| 33 | + | Repos: f.Repos, | |
| 34 | + | Browse: f.Browse, | |
| 35 | + | MergeRequests: f.MergeRequests, | |
| 36 | + | Events: f.EventLog, | |
| 38 | 37 | PublicURL: cfg.PublicURL, | |
| 39 | 38 | SourceURL: cfg.SourceURL, | |
| 40 | 39 | Version: serverVersion(), | |
| 41 | 40 | ||
| 42 | - | Sessions: webSessions{sessions}, | |
| 41 | + | Sessions: webSessions{f.Sessions}, | |
| 43 | 42 | Limiter: limiter, | |
| 44 | 43 | TrustedProxies: cfg.TrustedProxies, | |
| 45 | 44 | }) | |
| 46 | - | if err != nil { | |
| 47 | - | return nil, err | |
| 48 | - | } | |
| 49 | - | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | |
| 50 | 45 | } | |
| 51 | 46 | ||
| 52 | 47 | // sessionsNeedHTTPS reports whether browser sign-in cannot work on this public | |
| @@ -79,15 +74,34 @@ func warnSessionsNeedHTTPS(ctx context.Context, publicURL string) { | |||
| 79 | 74 | "and the session cookie is Secure (__Host-), so browsers drop it; serve the site over https") | |
| 80 | 75 | } | |
| 81 | 76 | ||
| 82 | - | // webSessions adapts the session use cases to web.SessionAuth: a valid token | |
| 83 | - | // gives the context of the owner, whose principal the API calls of the page | |
| 84 | - | // carry (rpc.API.InProcessClient). | |
| 85 | - | type webSessions struct{ *app.Sessions } | |
| 77 | + | // warnSharedClientKey tells the operator, at startup, that without trusted | |
| 78 | + | // proxies every request is attributed to the address of its TCP peer. Behind a | |
| 79 | + | // reverse proxy that is the proxy: all the clients then share one key, and so | |
| 80 | + | // one cap on the Git backends running at once (maxPerClient) and one budget of | |
| 81 | + | // failed authentications. The keying is unchanged; the fix is to list the | |
| 82 | + | // proxy networks. | |
| 83 | + | func warnSharedClientKey(ctx context.Context, trusted []netip.Prefix, maxPerClient int) { | |
| 84 | + | if len(trusted) > 0 || maxPerClient <= 0 { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | log := zerowrap.FromCtx(ctx) | |
| 88 | + | log.Warn().Int("max_git_backends_per_client", maxPerClient).Msg( | |
| 89 | + | config.EnvTrustedProxies + " is not set: clients are told apart by their TCP peer address only. " + | |
| 90 | + | "Behind a reverse proxy, all clients share the proxy's address, so one cap of concurrent Git " + | |
| 91 | + | "requests and one budget of failed authentications; set it to the proxy networks") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // webSessions adapts the session use cases to ports.WebSessionService: a valid | |
| 95 | + | // token gives the owner, the principal the web passes to the use cases for | |
| 96 | + | // that request. | |
| 97 | + | type webSessions struct{ *usecase.Sessions } | |
| 98 | + | ||
| 99 | + | var _ ports.WebSessionService = webSessions{} | |
| 86 | 100 | ||
| 87 | 101 | func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | |
| 88 | 102 | token, expiresAt, err := s.Sessions.Login(ctx, nonce) | |
| 89 | 103 | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 90 | - | return "", 0, web.ErrLinkRejected | |
| 104 | + | return "", 0, ports.ErrLinkRejected | |
| 91 | 105 | } | |
| 92 | 106 | if err != nil { | |
| 93 | 107 | return "", 0, err | |
| @@ -96,38 +110,25 @@ func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Dura | |||
| 96 | 110 | // that made the link may cut short. The session token carries its own | |
| 97 | 111 | // expiry, so a cookie that outlives it by under a second is harmless. | |
| 98 | 112 | // The expiry is truncated to the second, hence the slack on the full TTL. | |
| 99 | - | lifetime := app.SessionTTL | |
| 100 | - | if d := time.Until(expiresAt); d < app.SessionTTL-time.Second { | |
| 113 | + | lifetime := usecase.SessionTTL | |
| 114 | + | if d := time.Until(expiresAt); d < usecase.SessionTTL-time.Second { | |
| 101 | 115 | lifetime = d.Round(time.Second) | |
| 102 | 116 | } | |
| 103 | 117 | if lifetime < time.Second { | |
| 104 | - | return "", 0, web.ErrLinkRejected | |
| 118 | + | return "", 0, ports.ErrLinkRejected | |
| 105 | 119 | } | |
| 106 | 120 | return token, lifetime, nil | |
| 107 | 121 | } | |
| 108 | 122 | ||
| 109 | - | func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) { | |
| 123 | + | func (s webSessions) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { | |
| 110 | 124 | p, err := s.Sessions.Verify(ctx, token) | |
| 111 | 125 | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 112 | - | return ctx, false, nil | |
| 126 | + | return domain.Anonymous(), false, nil | |
| 113 | 127 | } | |
| 114 | 128 | if err != nil { | |
| 115 | - | return ctx, false, err | |
| 129 | + | return domain.Anonymous(), false, err | |
| 116 | 130 | } | |
| 117 | - | return domain.ContextWithPrincipal(ctx, p), true, nil | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // asPrincipal runs next with p as the caller of every request, attributed to | |
| 121 | - | // the browser's address: the API calls the web makes carry both | |
| 122 | - | // (rpc.API.InProcessClient), so the per-client limits of the API count each | |
| 123 | - | // visitor. Browsers are anonymous; web.Server replaces the principal with the | |
| 124 | - | // owner's when the request carries a valid session. | |
| 125 | - | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | |
| 126 | - | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 127 | - | ctx := domain.ContextWithPrincipal(r.Context(), p) | |
| 128 | - | ctx = rpc.ContextWithClient(ctx, clientlimit.ClientAddr(r, trusted)) | |
| 129 | - | next.ServeHTTP(w, r.WithContext(ctx)) | |
| 130 | - | }) | |
| 131 | + | return p, true, nil | |
| 131 | 132 | } | |
| 132 | 133 | ||
| 133 | 134 | // webRoutes mounts the web on the patterns it serves. | |
R cmd/ermite/web_commit_test.go → internal/app/web_commit_test.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R cmd/ermite/web_commits_test.go → internal/app/web_commits_test.go +3 -3 renamed, 97% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
| @@ -14,8 +14,8 @@ import ( | |||
| 14 | 14 | "github.com/stretchr/testify/assert" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | - | "bnema.dev/ermite/internal/app" | |
| 18 | 17 | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | // diffFixture is the repository of the commit pages: one commit for each | |
| @@ -27,7 +27,7 @@ type diffFixture struct { | |||
| 27 | 27 | // createRepo creates a public repository for the pages. | |
| 28 | 28 | func (h *harness) createRepo(name string, vis domain.Visibility) { | |
| 29 | 29 | h.t.Helper() | |
| 30 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) | |
| 30 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) | |
| 31 | 31 | require.NoError(h.t, err) | |
| 32 | 32 | } | |
| 33 | 33 | ||
R cmd/ermite/web_events_test.go → internal/app/web_events_test.go +10 -9 renamed, 94% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bufio" | |
| @@ -15,8 +15,9 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/assert" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/usecase" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| 22 | 23 | // sseEvent is one event read from /-/events. | |
| @@ -126,7 +127,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 126 | 127 | assert.Contains(t, ev.data, `href="/pub"`) | |
| 127 | 128 | ||
| 128 | 129 | // A new repository is a row; so is a repository created by a push. | |
| 129 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) | |
| 130 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic}) | |
| 130 | 131 | require.NoError(t, err) | |
| 131 | 132 | ev = s.next(t, watchWait) | |
| 132 | 133 | assert.Equal(t, "home-list", ev.name) | |
| @@ -136,7 +137,7 @@ func TestWebEventsHome(t *testing.T) { | |||
| 136 | 137 | ||
| 137 | 138 | // A repository that turns private is removed from the anonymous list. | |
| 138 | 139 | priv := domain.VisibilityPrivate | |
| 139 | - | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv}) | |
| 140 | + | _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), ports.UpdateRepoParams{Visibility: &priv}) | |
| 140 | 141 | require.NoError(t, err) | |
| 141 | 142 | ev = s.next(t, watchWait) | |
| 142 | 143 | assert.Equal(t, "home-list", ev.name) | |
| @@ -162,7 +163,7 @@ func TestWebEventsRepoFragments(t *testing.T) { | |||
| 162 | 163 | refs := h.sse("topic=repo:x&view=refs", "") | |
| 163 | 164 | ||
| 164 | 165 | _, _, page := h.get("/x/commits/main") | |
| 165 | - | require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner") | |
| 166 | + | require.Contains(t, page, `<div id="commits-head" class="live-slot" role="status" data-slot>`, "the slot of the banner, a status region that stays in the page") | |
| 166 | 167 | m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page) | |
| 167 | 168 | require.Len(t, m, 2, "the commits page is live") | |
| 168 | 169 | url := html.UnescapeString(m[1]) | |
| @@ -286,7 +287,7 @@ func TestWebEventsPrivate(t *testing.T) { | |||
| 286 | 287 | wt := h.workTree("main", "a.txt") | |
| 287 | 288 | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main") | |
| 288 | 289 | desc := "secret change" | |
| 289 | - | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc}) | |
| 290 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), ports.UpdateRepoParams{Description: &desc}) | |
| 290 | 291 | require.NoError(t, err) | |
| 291 | 292 | // Made public later, it does not reveal what happened while it was | |
| 292 | 293 | // private; the update that makes it public is shown. | |
| @@ -303,7 +304,7 @@ func TestWebEventsPrivate(t *testing.T) { | |||
| 303 | 304 | // public repository and requires its row to be the next event. | |
| 304 | 305 | func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) { | |
| 305 | 306 | t.Helper() | |
| 306 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 307 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic}) | |
| 307 | 308 | require.NoError(t, err) | |
| 308 | 309 | ev := s.next(t, watchWait) | |
| 309 | 310 | assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data) | |
| @@ -380,7 +381,7 @@ func TestWebEventsEndOnShutdown(t *testing.T) { | |||
| 380 | 381 | // one address cannot take every watch slot of the forge. | |
| 381 | 382 | func TestWebEventsLimitPerVisitor(t *testing.T) { | |
| 382 | 383 | h := newHarness(t, harnessOpts{}) | |
| 383 | - | for range app.MaxWatchersPerClient { | |
| 384 | + | for range usecase.MaxWatchersPerClient { | |
| 384 | 385 | h.sse("topic=home", "") | |
| 385 | 386 | } | |
| 386 | 387 | req, err := http.NewRequestWithContext(h.ctx, http.MethodGet, h.url("/-/events?topic=home"), nil) | |
| @@ -390,7 +391,7 @@ func TestWebEventsLimitPerVisitor(t *testing.T) { | |||
| 390 | 391 | defer func() { _ = resp.Body.Close() }() | |
| 391 | 392 | assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) | |
| 392 | 393 | assert.NotEmpty(t, resp.Header.Get("Retry-After")) | |
| 393 | - | assert.Equal(t, app.MaxWatchersPerClient, h.forge.EventLog.Watchers()) | |
| 394 | + | assert.Equal(t, usecase.MaxWatchersPerClient, h.forge.EventLog.Watchers()) | |
| 394 | 395 | } | |
| 395 | 396 | ||
| 396 | 397 | // A visitor that goes away frees its watch. | |
R cmd/ermite/web_files_test.go → internal/app/web_files_test.go +6 -6 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -22,8 +22,8 @@ import ( | |||
| 22 | 22 | "github.com/stretchr/testify/assert" | |
| 23 | 23 | "github.com/stretchr/testify/require" | |
| 24 | 24 | ||
| 25 | - | "bnema.dev/ermite/internal/app" | |
| 26 | 25 | "bnema.dev/ermite/internal/domain" | |
| 26 | + | "bnema.dev/ermite/internal/ports" | |
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | 29 | // Trojan Source: a right-to-left override and an isolate around code. | |
| @@ -43,7 +43,7 @@ func tinyPNG(t *testing.T) []byte { | |||
| 43 | 43 | // tag with a slash. | |
| 44 | 44 | func (h *harness) webFilesRepo(name string) { | |
| 45 | 45 | h.t.Helper() | |
| 46 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |
| 46 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ | |
| 47 | 47 | Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic, | |
| 48 | 48 | }) | |
| 49 | 49 | require.NoError(h.t, err) | |
| @@ -152,7 +152,7 @@ func TestWebTreeRows(t *testing.T) { | |||
| 152 | 152 | // that links to the directory page, which says the listing is cut. | |
| 153 | 153 | func TestWebTreeRowsTruncated(t *testing.T) { | |
| 154 | 154 | h := newHarness(t, harnessOpts{}) | |
| 155 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) | |
| 155 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) | |
| 156 | 156 | require.NoError(t, err) | |
| 157 | 157 | wt := h.t.TempDir() | |
| 158 | 158 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| @@ -396,7 +396,7 @@ func TestWebFilesContentSecurity(t *testing.T) { | |||
| 396 | 396 | h := newHarness(t, harnessOpts{}) | |
| 397 | 397 | h.webFilesRepo("files") | |
| 398 | 398 | ||
| 399 | - | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 399 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" | |
| 400 | 400 | for _, p := range []string{ | |
| 401 | 401 | "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md", | |
| 402 | 402 | "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt", | |
| @@ -529,7 +529,7 @@ func TestWebPaths(t *testing.T) { | |||
| 529 | 529 | // anonymous gets the 404 of a missing repository, as the owner still | |
| 530 | 530 | // reads it. | |
| 531 | 531 | priv := domain.VisibilityPrivate | |
| 532 | - | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), app.UpdateParams{Visibility: &priv}) | |
| 532 | + | _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), ports.UpdateRepoParams{Visibility: &priv}) | |
| 533 | 533 | require.NoError(t, err) | |
| 534 | 534 | for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} { | |
| 535 | 535 | p := b.get(path, hdr) | |
R cmd/ermite/web_mr_test.go → internal/app/web_mr_test.go +6 -6 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "html" | |
| @@ -13,8 +13,8 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | - | "bnema.dev/ermite/internal/app" | |
| 17 | 16 | "bnema.dev/ermite/internal/domain" | |
| 17 | + | "bnema.dev/ermite/internal/ports" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 20 | // mrRepo is a public repository "proj" with README.md on main and a branch | |
| @@ -67,7 +67,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 67 | 67 | anon := b.get("/proj/compare/feat/search", nil) | |
| 68 | 68 | require.Equal(t, 200, anon.status, anon.body) | |
| 69 | 69 | assert.Equal(t, "no-cache", anon.hdr.Get("Cache-Control")) | |
| 70 | - | assert.Empty(t, anon.hdr.Values("Vary")) | |
| 70 | + | assert.Equal(t, []string{"Accept-Encoding"}, anon.hdr.Values("Vary")) | |
| 71 | 71 | assert.Contains(t, anon.body, "feat/search") | |
| 72 | 72 | assert.Contains(t, anon.body, "package search</td>", "the diff of the single commit") | |
| 73 | 73 | assert.NotContains(t, anon.body, "<form class=\"cform create\"") | |
| @@ -129,7 +129,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 129 | 129 | mr := b.get("/proj/merge-requests/1", nil) | |
| 130 | 130 | require.Equal(t, 200, mr.status, mr.body) | |
| 131 | 131 | assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control")) | |
| 132 | - | assert.Empty(t, mr.hdr.Values("Vary")) | |
| 132 | + | assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary")) | |
| 133 | 133 | assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add <b>search</b></h1>") | |
| 134 | 134 | assert.NotContains(t, mr.body, "<b>search</b>") | |
| 135 | 135 | assert.Contains(t, mr.body, "feat/search") | |
| @@ -466,7 +466,7 @@ func TestWebMergeRequestLimitIsFriendly(t *testing.T) { | |||
| 466 | 466 | token := r.signIn(t) | |
| 467 | 467 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "t").ok(t) | |
| 468 | 468 | for i := 0; i < 1000; i++ { | |
| 469 | - | _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, app.AddCommentParams{Text: "n"}) | |
| 469 | + | _, err := r.forge.MergeRequests.AddComment(r.ctx, domain.Root(), r.name("proj"), 1, ports.AddCommentParams{Text: "n"}) | |
| 470 | 470 | require.NoError(t, err) | |
| 471 | 471 | } | |
| 472 | 472 | full := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"one more"}}) | |
| @@ -511,7 +511,7 @@ func TestWebMergeRequestEvents(t *testing.T) { | |||
| 511 | 511 | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t) | |
| 512 | 512 | ev := list.next(t, watchWait) | |
| 513 | 513 | assert.Equal(t, "mr-notice", ev.name) | |
| 514 | - | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-banner" role="status">`) | |
| 514 | + | assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`) | |
| 515 | 515 | assert.Contains(t, ev.data, `href="/proj/merge-requests"`) | |
| 516 | 516 | assert.NotContains(t, ev.data, "Secret") | |
| 517 | 517 | ||
R cmd/ermite/web_session_test.go → internal/app/web_session_test.go +49 -11 renamed, 92% similar
| @@ -1,11 +1,12 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | + | "net/netip" | |
| 9 | 10 | "net/url" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "testing" | |
| @@ -18,8 +19,8 @@ import ( | |||
| 18 | 19 | "github.com/stretchr/testify/require" | |
| 19 | 20 | ||
| 20 | 21 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 21 | - | "bnema.dev/ermite/internal/app" | |
| 22 | 22 | "bnema.dev/ermite/internal/domain" | |
| 23 | + | "bnema.dev/ermite/internal/ports" | |
| 23 | 24 | ) | |
| 24 | 25 | ||
| 25 | 26 | const ( | |
| @@ -115,7 +116,7 @@ func TestWebLoginEndToEnd(t *testing.T) { | |||
| 115 | 116 | assert.Contains(t, home.body, "pub") | |
| 116 | 117 | assert.NotContains(t, home.body, "secret") | |
| 117 | 118 | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | |
| 118 | - | assert.Empty(t, home.hdr.Values("Vary")) | |
| 119 | + | assert.Equal(t, []string{"Accept-Encoding"}, home.hdr.Values("Vary"), "no Cookie in the Vary of a public page") | |
| 119 | 120 | assert.NotContains(t, home.body, `action="/-/logout"`) | |
| 120 | 121 | assert.Equal(t, 404, b.get("/secret", nil).status) | |
| 121 | 122 | ||
| @@ -310,7 +311,7 @@ func TestWebRevokingAnOwnerTokenSignsEveryoneOut(t *testing.T) { | |||
| 310 | 311 | ||
| 311 | 312 | // The cap: a link made by a token that expires in an hour gives a cookie, | |
| 312 | 313 | // and a session, of an hour. | |
| 313 | - | soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), app.CreateTokenParams{ | |
| 314 | + | soon, err := c.forge.Tokens.Create(c.ctx, domain.Root(), ports.CreateTokenParams{ | |
| 314 | 315 | Label: "soon", Scopes: domain.AllScopes(), ExpiresAt: time.Now().Add(time.Hour), | |
| 315 | 316 | }) | |
| 316 | 317 | require.NoError(t, err) | |
| @@ -505,14 +506,22 @@ func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) { | |||
| 505 | 506 | assert.NotContains(t, logs, string(key)) | |
| 506 | 507 | } | |
| 507 | 508 | ||
| 508 | - | // The owner principal is what the API sees from a signed-in page, and is | |
| 509 | - | // labelled "owner". | |
| 509 | + | // The owner principal is what the use cases see from a signed-in page, and is | |
| 510 | + | // labelled "owner": the use case logs it with a write the page made. | |
| 510 | 511 | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | |
| 511 | - | c := newCLIHarness(t) | |
| 512 | - | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 513 | - | token := c.signIn(t) | |
| 514 | - | browser{c.harness}.get("/secret", as(token)) | |
| 515 | - | assert.Contains(t, c.logs.String(), `"principal":"owner"`) | |
| 512 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 513 | + | token := r.signIn(t) | |
| 514 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t) | |
| 515 | + | b := browser{r.harness} | |
| 516 | + | res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"from the page"}}) | |
| 517 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 518 | + | var line string | |
| 519 | + | for _, l := range strings.Split(r.logs.String(), "\n") { | |
| 520 | + | if strings.Contains(l, `"comment added"`) { | |
| 521 | + | line = l | |
| 522 | + | } | |
| 523 | + | } | |
| 524 | + | assert.Contains(t, line, `"principal":"owner"`) | |
| 516 | 525 | } | |
| 517 | 526 | ||
| 518 | 527 | // GO-004: a Secure cookie is kept by browsers only from https (or a loopback | |
| @@ -558,3 +567,32 @@ func TestStartupWarnsWhenSignInCannotWork(t *testing.T) { | |||
| 558 | 567 | warnSessionsNeedHTTPS(ctx, "http://forge.example") | |
| 559 | 568 | assert.Contains(t, buf.String(), "ERMITE_PUBLIC_URL") | |
| 560 | 569 | } | |
| 570 | + | ||
| 571 | + | func TestWarnSharedClientKey(t *testing.T) { | |
| 572 | + | t.Parallel() | |
| 573 | + | proxies := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")} | |
| 574 | + | tests := []struct { | |
| 575 | + | name string | |
| 576 | + | trusted []netip.Prefix | |
| 577 | + | cap int | |
| 578 | + | warns bool | |
| 579 | + | }{ | |
| 580 | + | {"no proxy, cap active", nil, 4, true}, | |
| 581 | + | {"proxies trusted", proxies, 4, false}, | |
| 582 | + | {"no proxy, no cap", nil, 0, false}, | |
| 583 | + | } | |
| 584 | + | for _, tt := range tests { | |
| 585 | + | t.Run(tt.name, func(t *testing.T) { | |
| 586 | + | t.Parallel() | |
| 587 | + | var buf syncBuffer | |
| 588 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &buf})) | |
| 589 | + | warnSharedClientKey(ctx, tt.trusted, tt.cap) | |
| 590 | + | if !tt.warns { | |
| 591 | + | assert.Empty(t, buf.String()) | |
| 592 | + | return | |
| 593 | + | } | |
| 594 | + | assert.Contains(t, buf.String(), `"level":"warn"`) | |
| 595 | + | assert.Contains(t, buf.String(), "ERMITE_TRUSTED_PROXIES") | |
| 596 | + | }) | |
| 597 | + | } | |
| 598 | + | } | |
R cmd/ermite/web_test.go → internal/app/web_test.go +6 -6 renamed, 96% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package main | |
| 3 | + | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "flag" | |
| @@ -16,8 +16,8 @@ import ( | |||
| 16 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | 18 | ||
| 19 | - | "bnema.dev/ermite/internal/app" | |
| 20 | 19 | "bnema.dev/ermite/internal/domain" | |
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") | |
| @@ -30,7 +30,7 @@ const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) | |||
| 30 | 30 | // branches (one with a slash), two tags and a description. | |
| 31 | 31 | func (h *harness) webRepo(name string) { | |
| 32 | 32 | h.t.Helper() | |
| 33 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |
| 33 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ | |
| 34 | 34 | Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, | |
| 35 | 35 | }) | |
| 36 | 36 | require.NoError(h.t, err) | |
| @@ -100,7 +100,7 @@ func golden(t *testing.T, name, got string) { | |||
| 100 | 100 | return | |
| 101 | 101 | } | |
| 102 | 102 | want, err := os.ReadFile(path) | |
| 103 | - | require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update") | |
| 103 | + | require.NoError(t, err, "run: go test ./internal/app -run TestWebGolden -update") | |
| 104 | 104 | assert.Equal(t, string(want), got, name) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| @@ -162,7 +162,7 @@ func TestWebPrivateRepoIsInvisible(t *testing.T) { | |||
| 162 | 162 | // AC4, end to end: a README that attacks the page is neutralised. | |
| 163 | 163 | func TestWebReadmeIsNeutralised(t *testing.T) { | |
| 164 | 164 | h := newHarness(t, harnessOpts{}) | |
| 165 | - | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) | |
| 165 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) | |
| 166 | 166 | require.NoError(t, err) | |
| 167 | 167 | wt := h.t.TempDir() | |
| 168 | 168 | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| @@ -207,7 +207,7 @@ func TestWebContentSecurity(t *testing.T) { | |||
| 207 | 207 | h.webRepo("demo") | |
| 208 | 208 | h.seed("quiet", domain.VisibilityPublic) | |
| 209 | 209 | ||
| 210 | - | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 210 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" | |
| 211 | 211 | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | |
| 212 | 212 | status, hdr, body := h.get(p) | |
| 213 | 213 | assert.Contains(t, []int{200, 404}, status, p) | |
A internal/boundary/boundary_test.go +341 -0
| @@ -0,0 +1,341 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package boundary_test enforces the import rules between the layers of | |
| 4 | + | // ermite. The module root holds no Go package (the binary lives in cmd/ermite), | |
| 5 | + | // so the check lives in this test-only package and walks the module from its | |
| 6 | + | // root. It parses import clauses only; it never loads packages. | |
| 7 | + | package boundary_test | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "fmt" | |
| 11 | + | "go/parser" | |
| 12 | + | "go/token" | |
| 13 | + | "io/fs" | |
| 14 | + | "path/filepath" | |
| 15 | + | "sort" | |
| 16 | + | "strconv" | |
| 17 | + | "strings" | |
| 18 | + | "testing" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const modulePath = "bnema.dev/ermite" | |
| 22 | + | ||
| 23 | + | // apiGenPath is the generated code of the API contract (protobuf and Connect), | |
| 24 | + | // a module of its own. | |
| 25 | + | const apiGenPath = "bnema.dev/ermite-api/gen" | |
| 26 | + | ||
| 27 | + | // moduleRoot is the module root relative to this package's directory. | |
| 28 | + | const moduleRoot = "../.." | |
| 29 | + | ||
| 30 | + | type packageLayer string | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | layerCommand packageLayer = "command" | |
| 34 | + | layerApp packageLayer = "app" | |
| 35 | + | layerUsecase packageLayer = "usecase" | |
| 36 | + | layerAdapter packageLayer = "adapter" | |
| 37 | + | layerClientLimit packageLayer = "clientlimit" | |
| 38 | + | layerConfig packageLayer = "config" | |
| 39 | + | layerPorts packageLayer = "ports" | |
| 40 | + | layerDomain packageLayer = "domain" | |
| 41 | + | layerGenerated packageLayer = "generated" | |
| 42 | + | layerScript packageLayer = "script" | |
| 43 | + | layerBoundary packageLayer = "boundary" | |
| 44 | + | layerTestSupport packageLayer = "test-support" | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // productionDependencies lists, per layer, the layers its production files | |
| 48 | + | // may import. | |
| 49 | + | // | |
| 50 | + | // - cmd/ermite is the entry point: it only calls internal/app. | |
| 51 | + | // - internal/app is the composition root and the CLI: it wires everything. | |
| 52 | + | // - internal/usecase holds the use cases: domain and ports only. | |
| 53 | + | // - adapters implement ports and never import use cases or app; the | |
| 54 | + | // per-package rule in packageImportDenied also keeps them from importing | |
| 55 | + | // each other, and keeps the web adapter off the generated API code: like | |
| 56 | + | // every other adapter it reads and writes through the ports (ADR 0020). | |
| 57 | + | // - internal/clientlimit is infrastructure shared by the HTTP adapters. | |
| 58 | + | // - internal/config parses the environment into plain values. | |
| 59 | + | var productionDependencies = map[packageLayer]map[packageLayer]bool{ | |
| 60 | + | layerCommand: {layerApp: true}, | |
| 61 | + | layerApp: {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 62 | + | layerUsecase: {layerUsecase: true, layerPorts: true, layerDomain: true}, | |
| 63 | + | layerAdapter: {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true}, | |
| 64 | + | layerClientLimit: {layerPorts: true}, | |
| 65 | + | layerConfig: {}, | |
| 66 | + | layerPorts: {layerDomain: true}, | |
| 67 | + | layerDomain: {layerDomain: true}, | |
| 68 | + | layerGenerated: {layerGenerated: true}, | |
| 69 | + | layerScript: {}, | |
| 70 | + | layerBoundary: {}, | |
| 71 | + | layerTestSupport: {layerPorts: true, layerDomain: true}, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestImportBoundaries(t *testing.T) { | |
| 75 | + | violations, packages := inspectRepositoryDependencies(t) | |
| 76 | + | if len(violations) != 0 { | |
| 77 | + | t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n")) | |
| 78 | + | } | |
| 79 | + | for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} { | |
| 80 | + | if _, ok := packages[modulePath+"/"+want]; !ok { | |
| 81 | + | t.Fatalf("package inventory misses %s: the walk is not reading the module", want) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // The generated API code is outside the module: the walk would skip it. The | |
| 87 | + | // web adapter may not import it in production code, whichever package of it. | |
| 88 | + | func TestWebAdapterRejectsAPIGeneratedCode(t *testing.T) { | |
| 89 | + | tests := []struct { | |
| 90 | + | name, source, target string | |
| 91 | + | testFile bool | |
| 92 | + | want bool | |
| 93 | + | }{ | |
| 94 | + | {"web messages", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", false, false}, | |
| 95 | + | {"web connect", modulePath + "/internal/adapters/web", apiGenPath + "/ermite/ermiteconnect", false, false}, | |
| 96 | + | {"web subpackage", modulePath + "/internal/adapters/web/sub", apiGenPath + "/ermite", false, false}, | |
| 97 | + | {"web test file", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", true, true}, | |
| 98 | + | {"rpc adapter", modulePath + "/internal/adapters/rpc", apiGenPath + "/ermite", false, true}, | |
| 99 | + | {"client adapter", modulePath + "/internal/adapters/client", apiGenPath + "/ermite/ermiteconnect", false, true}, | |
| 100 | + | {"other module of the API", modulePath + "/internal/adapters/web", "bnema.dev/ermite-api/other", false, true}, | |
| 101 | + | {"web other external package", modulePath + "/internal/adapters/web", "github.com/a-h/templ", false, true}, | |
| 102 | + | {"a package whose name only starts alike", modulePath + "/internal/adapters/web", apiGenPath + "x/y", false, true}, | |
| 103 | + | } | |
| 104 | + | for _, tt := range tests { | |
| 105 | + | t.Run(tt.name, func(t *testing.T) { | |
| 106 | + | if got := !externalImportDenied(tt.source, tt.target, tt.testFile); got != tt.want { | |
| 107 | + | t.Fatalf("externalImportDenied(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, !got, !tt.want) | |
| 108 | + | } | |
| 109 | + | }) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestImportBoundaryFixtures(t *testing.T) { | |
| 114 | + | p := func(s string) string { return modulePath + "/" + s } | |
| 115 | + | tests := []struct { | |
| 116 | + | name, source, target string | |
| 117 | + | testFile bool | |
| 118 | + | want bool | |
| 119 | + | }{ | |
| 120 | + | {"command accepts app", p("cmd/ermite"), p("internal/app"), false, true}, | |
| 121 | + | {"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false}, | |
| 122 | + | {"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false}, | |
| 123 | + | {"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false}, | |
| 124 | + | {"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true}, | |
| 125 | + | {"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true}, | |
| 126 | + | {"app accepts config", p("internal/app"), p("internal/config"), false, true}, | |
| 127 | + | {"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true}, | |
| 128 | + | {"app rejects command", p("internal/app"), p("cmd/ermite"), false, false}, | |
| 129 | + | {"app test may import command", p("internal/app"), p("cmd/ermite"), true, true}, | |
| 130 | + | {"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true}, | |
| 131 | + | {"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true}, | |
| 132 | + | {"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false}, | |
| 133 | + | {"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false}, | |
| 134 | + | {"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false}, | |
| 135 | + | {"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false}, | |
| 136 | + | {"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true}, | |
| 137 | + | {"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true}, | |
| 138 | + | {"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true}, | |
| 139 | + | {"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true}, | |
| 140 | + | {"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true}, | |
| 141 | + | {"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, true}, | |
| 142 | + | {"web rejects generated", p("internal/adapters/web"), p("gen/ermite"), false, false}, | |
| 143 | + | {"web rejects generated connect", p("internal/adapters/web"), p("gen/ermite/ermiteconnect"), false, false}, | |
| 144 | + | {"web subpackage rejects generated", p("internal/adapters/web/sub"), p("gen/ermite"), false, false}, | |
| 145 | + | {"web test may import ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), true, true}, | |
| 146 | + | {"web production rejects ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), false, false}, | |
| 147 | + | {"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false}, | |
| 148 | + | {"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false}, | |
| 149 | + | {"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false}, | |
| 150 | + | {"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false}, | |
| 151 | + | {"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true}, | |
| 152 | + | {"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true}, | |
| 153 | + | {"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false}, | |
| 154 | + | {"config rejects domain", p("internal/config"), p("internal/domain"), false, false}, | |
| 155 | + | {"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true}, | |
| 156 | + | {"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false}, | |
| 157 | + | {"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false}, | |
| 158 | + | {"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true}, | |
| 159 | + | {"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false}, | |
| 160 | + | {"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true}, | |
| 161 | + | {"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false}, | |
| 162 | + | {"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true}, | |
| 163 | + | {"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false}, | |
| 164 | + | {"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true}, | |
| 165 | + | } | |
| 166 | + | for _, tt := range tests { | |
| 167 | + | t.Run(tt.name, func(t *testing.T) { | |
| 168 | + | got, err := dependencyAllowed(tt.source, tt.target, tt.testFile) | |
| 169 | + | if err != nil { | |
| 170 | + | t.Fatalf("dependencyAllowed: %v", err) | |
| 171 | + | } | |
| 172 | + | if got != tt.want { | |
| 173 | + | t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want) | |
| 174 | + | } | |
| 175 | + | }) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) { | |
| 180 | + | t.Helper() | |
| 181 | + | var violations []string | |
| 182 | + | packages := make(map[string]struct{}) | |
| 183 | + | fset := token.NewFileSet() | |
| 184 | + | err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error { | |
| 185 | + | if walkErr != nil { | |
| 186 | + | return walkErr | |
| 187 | + | } | |
| 188 | + | if entry.IsDir() { | |
| 189 | + | name := entry.Name() | |
| 190 | + | if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" { | |
| 191 | + | return filepath.SkipDir | |
| 192 | + | } | |
| 193 | + | return nil | |
| 194 | + | } | |
| 195 | + | if !strings.HasSuffix(path, ".go") { | |
| 196 | + | return nil | |
| 197 | + | } | |
| 198 | + | rel, err := filepath.Rel(moduleRoot, path) | |
| 199 | + | if err != nil { | |
| 200 | + | return err | |
| 201 | + | } | |
| 202 | + | source := importPathForFile(rel) | |
| 203 | + | packages[source] = struct{}{} | |
| 204 | + | if _, err := classifyPackage(source); err != nil { | |
| 205 | + | violations = append(violations, rel+": "+err.Error()) | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) | |
| 209 | + | if err != nil { | |
| 210 | + | return err | |
| 211 | + | } | |
| 212 | + | testFile := strings.HasSuffix(path, "_test.go") | |
| 213 | + | for _, spec := range file.Imports { | |
| 214 | + | target, err := strconv.Unquote(spec.Path.Value) | |
| 215 | + | if err != nil { | |
| 216 | + | continue | |
| 217 | + | } | |
| 218 | + | if externalImportDenied(source, target, testFile) { | |
| 219 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |
| 220 | + | continue | |
| 221 | + | } | |
| 222 | + | if target != modulePath && !strings.HasPrefix(target, modulePath+"/") { | |
| 223 | + | continue | |
| 224 | + | } | |
| 225 | + | allowed, err := dependencyAllowed(source, target, testFile) | |
| 226 | + | if err != nil { | |
| 227 | + | violations = append(violations, fmt.Sprintf("%s: %v", rel, err)) | |
| 228 | + | continue | |
| 229 | + | } | |
| 230 | + | if !allowed { | |
| 231 | + | violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target)) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | return nil | |
| 235 | + | }) | |
| 236 | + | if err != nil { | |
| 237 | + | t.Fatalf("walking module: %v", err) | |
| 238 | + | } | |
| 239 | + | sort.Strings(violations) | |
| 240 | + | return violations, packages | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // dependencyAllowed reports whether a file of package source may import | |
| 244 | + | // target. Test files are exempt, so that composition tests can wire adapters | |
| 245 | + | // and use cases together; their packages must still be classified. | |
| 246 | + | func dependencyAllowed(source, target string, testFile bool) (bool, error) { | |
| 247 | + | sourceLayer, err := classifyPackage(source) | |
| 248 | + | if err != nil { | |
| 249 | + | return false, err | |
| 250 | + | } | |
| 251 | + | targetLayer, err := classifyPackage(target) | |
| 252 | + | if err != nil { | |
| 253 | + | return false, err | |
| 254 | + | } | |
| 255 | + | if testFile { | |
| 256 | + | return true, nil | |
| 257 | + | } | |
| 258 | + | if packageImportDenied(source, target) { | |
| 259 | + | return false, nil | |
| 260 | + | } | |
| 261 | + | return productionDependencies[sourceLayer][targetLayer], nil | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // externalImportDenied reports whether a file of package source may not import | |
| 265 | + | // target, a package outside the module. The only such rule: the production | |
| 266 | + | // code of the web adapter does not import the generated API code | |
| 267 | + | // (apiGenPath), so that it goes through the ports. Test files are exempt, like | |
| 268 | + | // for every rule here. | |
| 269 | + | func externalImportDenied(source, target string, testFile bool) bool { | |
| 270 | + | if testFile || !under(target, apiGenPath) { | |
| 271 | + | return false | |
| 272 | + | } | |
| 273 | + | return under(source, modulePath+"/internal/adapters/web") | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // packageImportDenied narrows the layer rules to packages: an adapter may | |
| 277 | + | // import only packages under its own directory, never a sibling adapter; and | |
| 278 | + | // the web adapter may not import the generated API code at all (protobuf, | |
| 279 | + | // Connect): it goes through the ports, as the other adapters that serve the | |
| 280 | + | // use cases do. | |
| 281 | + | func packageImportDenied(source, target string) bool { | |
| 282 | + | const adapters = modulePath + "/internal/adapters/" | |
| 283 | + | if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) { | |
| 284 | + | return adapterRoot(source) != adapterRoot(target) | |
| 285 | + | } | |
| 286 | + | if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" { | |
| 287 | + | return under(target, modulePath+"/gen") | |
| 288 | + | } | |
| 289 | + | return false | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // adapterRoot is the first path element under internal/adapters. | |
| 293 | + | func adapterRoot(path string) string { | |
| 294 | + | rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/") | |
| 295 | + | root, _, _ := strings.Cut(rest, "/") | |
| 296 | + | return root | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func under(path, root string) bool { | |
| 300 | + | return path == root || strings.HasPrefix(path, root+"/") | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func classifyPackage(path string) (packageLayer, error) { | |
| 304 | + | p := func(s string) string { return modulePath + "/" + s } | |
| 305 | + | switch { | |
| 306 | + | case under(path, p("cmd")): | |
| 307 | + | return layerCommand, nil | |
| 308 | + | case under(path, p("scripts")): | |
| 309 | + | return layerScript, nil | |
| 310 | + | case under(path, p("internal/boundary")): | |
| 311 | + | return layerBoundary, nil | |
| 312 | + | case under(path, p("internal/app")): | |
| 313 | + | return layerApp, nil | |
| 314 | + | case under(path, p("internal/usecase")): | |
| 315 | + | return layerUsecase, nil | |
| 316 | + | case path == p("internal/ports/mocks"), under(path, p("internal/readertest")): | |
| 317 | + | return layerTestSupport, nil | |
| 318 | + | case under(path, p("internal/adapters")): | |
| 319 | + | return layerAdapter, nil | |
| 320 | + | case under(path, p("internal/clientlimit")): | |
| 321 | + | return layerClientLimit, nil | |
| 322 | + | case under(path, p("internal/config")): | |
| 323 | + | return layerConfig, nil | |
| 324 | + | case path == p("internal/ports"): | |
| 325 | + | return layerPorts, nil | |
| 326 | + | case under(path, p("internal/domain")): | |
| 327 | + | return layerDomain, nil | |
| 328 | + | case under(path, p("gen")): | |
| 329 | + | return layerGenerated, nil | |
| 330 | + | default: | |
| 331 | + | return "", fmt.Errorf("unclassified package %q", path) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func importPathForFile(rel string) string { | |
| 336 | + | dir := filepath.ToSlash(filepath.Dir(rel)) | |
| 337 | + | if dir == "." { | |
| 338 | + | return modulePath | |
| 339 | + | } | |
| 340 | + | return modulePath + "/" + dir | |
| 341 | + | } | |
M internal/clientlimit/limiter.go +4 -0
| @@ -16,6 +16,8 @@ import ( | |||
| 16 | 16 | "time" | |
| 17 | 17 | ||
| 18 | 18 | "github.com/bnema/zerowrap" | |
| 19 | + | ||
| 20 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| 21 | 23 | // Limiter defaults: 10 failed authentications in 10 minutes block the client | |
| @@ -54,6 +56,8 @@ type Limiter struct { | |||
| 54 | 56 | lastSweep time.Time | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 59 | + | var _ ports.FailureLimiter = (*Limiter)(nil) | |
| 60 | + | ||
| 57 | 61 | // NewLimiter builds a limiter. now defaults to time.Now. | |
| 58 | 62 | func NewLimiter(now func() time.Time) *Limiter { | |
| 59 | 63 | if now == nil { | |
M internal/clientlimit/limiter_test.go +9 -0
| @@ -23,6 +23,7 @@ type fakeClock struct{ t time.Time } | |||
| 23 | 23 | func (c *fakeClock) now() time.Time { return c.t } | |
| 24 | 24 | ||
| 25 | 25 | func TestLimiter(t *testing.T) { | |
| 26 | + | t.Parallel() | |
| 26 | 27 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 27 | 28 | l := clientlimit.NewLimiter(clk.now) | |
| 28 | 29 | ||
| @@ -52,6 +53,7 @@ func TestLimiter(t *testing.T) { | |||
| 52 | 53 | } | |
| 53 | 54 | ||
| 54 | 55 | func TestLimiterSlidingWindow(t *testing.T) { | |
| 56 | + | t.Parallel() | |
| 55 | 57 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 56 | 58 | l := clientlimit.NewLimiter(clk.now) | |
| 57 | 59 | for range 5 { | |
| @@ -71,6 +73,7 @@ func TestLimiterSlidingWindow(t *testing.T) { | |||
| 71 | 73 | } | |
| 72 | 74 | ||
| 73 | 75 | func TestLimiterOneAddressStaysSmall(t *testing.T) { | |
| 76 | + | t.Parallel() | |
| 74 | 77 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 75 | 78 | l := clientlimit.NewLimiter(clk.now) | |
| 76 | 79 | for range 5000 { | |
| @@ -82,6 +85,7 @@ func TestLimiterOneAddressStaysSmall(t *testing.T) { | |||
| 82 | 85 | } | |
| 83 | 86 | ||
| 84 | 87 | func TestLimiterBoundsMemory(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 85 | 89 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 86 | 90 | l := clientlimit.NewLimiter(clk.now) | |
| 87 | 91 | for i := range clientlimit.MaxTracked + 5000 { | |
| @@ -103,6 +107,7 @@ func TestLimiterBoundsMemory(t *testing.T) { | |||
| 103 | 107 | } | |
| 104 | 108 | ||
| 105 | 109 | func TestLimiterSweepsExpiredEntriesOncePerInterval(t *testing.T) { | |
| 110 | + | t.Parallel() | |
| 106 | 111 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 107 | 112 | l := clientlimit.NewLimiter(clk.now) | |
| 108 | 113 | for i := range 100 { | |
| @@ -135,6 +140,7 @@ func BenchmarkLimiterFailAtMaxTracked(b *testing.B) { | |||
| 135 | 140 | } | |
| 136 | 141 | ||
| 137 | 142 | func TestLimiterGroupsIPv6By64(t *testing.T) { | |
| 143 | + | t.Parallel() | |
| 138 | 144 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 139 | 145 | l := clientlimit.NewLimiter(clk.now) | |
| 140 | 146 | ||
| @@ -155,6 +161,7 @@ func TestLimiterGroupsIPv6By64(t *testing.T) { | |||
| 155 | 161 | } | |
| 156 | 162 | ||
| 157 | 163 | func TestLimiterIPv4StaysPerAddress(t *testing.T) { | |
| 164 | + | t.Parallel() | |
| 158 | 165 | clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)} | |
| 159 | 166 | l := clientlimit.NewLimiter(clk.now) | |
| 160 | 167 | for range clientlimit.MaxFailures { | |
| @@ -170,6 +177,7 @@ func TestLimiterIPv4StaysPerAddress(t *testing.T) { | |||
| 170 | 177 | } | |
| 171 | 178 | ||
| 172 | 179 | func TestClientAddr(t *testing.T) { | |
| 180 | + | t.Parallel() | |
| 173 | 181 | trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")} | |
| 174 | 182 | tests := []struct { | |
| 175 | 183 | name string | |
| @@ -194,6 +202,7 @@ func TestClientAddr(t *testing.T) { | |||
| 194 | 202 | } | |
| 195 | 203 | for _, tt := range tests { | |
| 196 | 204 | t.Run(tt.name, func(t *testing.T) { | |
| 205 | + | t.Parallel() | |
| 197 | 206 | r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}} | |
| 198 | 207 | for _, v := range tt.xff { | |
| 199 | 208 | r.Header.Add("X-Forwarded-For", v) | |
M internal/config/config.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | 3 | // Package config parses and validates the server configuration from the | |
| 4 | - | // environment. It is an entrypoint helper: only cmd imports it, and it never | |
| 4 | + | // environment. It is a composition-root helper: only internal/app imports it, and it never | |
| 5 | 5 | // reads the process environment itself, the caller passes a lookup function. | |
| 6 | 6 | package config | |
| 7 | 7 | ||
M internal/domain/errors.go +22 -0
| @@ -37,3 +37,25 @@ var ( | |||
| 37 | 37 | // the number of event watchers; the caller may retry later. | |
| 38 | 38 | ErrResourceExhausted = errors.New("resource exhausted") | |
| 39 | 39 | ) | |
| 40 | + | ||
| 41 | + | // invalidErrors are the errors that mean a request was malformed. | |
| 42 | + | var invalidErrors = []error{ | |
| 43 | + | ErrInvalid, ErrInvalidName, ErrInvalidVisibility, | |
| 44 | + | ErrInvalidScope, ErrInvalidToken, | |
| 45 | + | ErrInvalidObjectID, ErrInvalidRevision, ErrInvalidPath, | |
| 46 | + | ErrInvalidPageToken, ErrInvalidPageSize, ErrInvalidRefKind, | |
| 47 | + | ErrNotDirectory, ErrNotFile, ErrInvalidText, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // IsInvalid reports whether err means that the request was malformed: an | |
| 51 | + | // invalid name, revision, path, page token, text, and the like. The adapters | |
| 52 | + | // answer all of them alike (invalid_argument on the API, the 404 page on the | |
| 53 | + | // web). | |
| 54 | + | func IsInvalid(err error) bool { | |
| 55 | + | for _, invalid := range invalidErrors { | |
| 56 | + | if errors.Is(err, invalid) { | |
| 57 | + | return true | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return false | |
| 61 | + | } | |
A internal/domain/errors_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestIsInvalid(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | var named *InvalidNameError | |
| 16 | + | _, parseErr := ParseRepoName("Bad Name") | |
| 17 | + | assert.ErrorAs(t, parseErr, &named) | |
| 18 | + | ||
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | err error | |
| 22 | + | want bool | |
| 23 | + | }{ | |
| 24 | + | {"nil", nil, false}, | |
| 25 | + | {"invalid", ErrInvalid, true}, | |
| 26 | + | {"wrapped invalid", fmt.Errorf("x: %w", ErrInvalidRevision), true}, | |
| 27 | + | {"invalid name from the parser", parseErr, true}, | |
| 28 | + | {"invalid fields", &InvalidFieldsError{Fields: []FieldViolation{{Field: "title", Reason: "is empty"}}}, true}, | |
| 29 | + | {"page token", ErrInvalidPageToken, true}, | |
| 30 | + | {"not a file", ErrNotFile, true}, | |
| 31 | + | {"invalid text", ErrInvalidText, true}, | |
| 32 | + | {"not found is not invalid", ErrNotFound, false}, | |
| 33 | + | {"forbidden is not invalid", ErrForbidden, false}, | |
| 34 | + | {"other", errors.New("boom"), false}, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | t.Parallel() | |
| 39 | + | assert.Equal(t, tt.want, IsInvalid(tt.err)) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
M internal/domain/event_test.go +2 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func TestNewRefUpdate(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 16 | 17 | a, b := strings.Repeat("a", 40), strings.Repeat("b", 40) | |
| 17 | 18 | ||
| 18 | 19 | u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main") | |
| @@ -46,6 +47,7 @@ func TestNewRefUpdate(t *testing.T) { | |||
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | 49 | func TestEventKinds(t *testing.T) { | |
| 50 | + | t.Parallel() | |
| 49 | 51 | assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind()) | |
| 50 | 52 | assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind()) | |
| 51 | 53 | assert.Equal(t, "gap", domain.EventGap{}.EventKind()) | |
M internal/domain/git_test.go +6 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func TestParseObjectID(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 16 | 17 | good := strings.Repeat("ab12", 10) | |
| 17 | 18 | id, err := domain.ParseObjectID(good) | |
| 18 | 19 | require.NoError(t, err) | |
| @@ -27,6 +28,7 @@ func TestParseObjectID(t *testing.T) { | |||
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | func TestParseRevision(t *testing.T) { | |
| 31 | + | t.Parallel() | |
| 30 | 32 | valid := []string{ | |
| 31 | 33 | "", "main", "feature/x", "refs/heads/feature/x", "v1.0.0", "HEAD", | |
| 32 | 34 | "abc1234", strings.Repeat("a", 40), "release-2026.10", "café", | |
| @@ -47,6 +49,7 @@ func TestParseRevision(t *testing.T) { | |||
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | func TestRevisionKinds(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 50 | 53 | head, _ := domain.ParseRevision("") | |
| 51 | 54 | assert.True(t, head.IsHead()) | |
| 52 | 55 | full, _ := domain.ParseRevision(strings.Repeat("a", 40)) | |
| @@ -64,6 +67,7 @@ func TestRevisionKinds(t *testing.T) { | |||
| 64 | 67 | } | |
| 65 | 68 | ||
| 66 | 69 | func TestParseTreePath(t *testing.T) { | |
| 70 | + | t.Parallel() | |
| 67 | 71 | root, err := domain.ParseTreePath("") | |
| 68 | 72 | require.NoError(t, err) | |
| 69 | 73 | assert.True(t, root.IsRoot()) | |
| @@ -150,6 +154,7 @@ func TestPathListAdd(t *testing.T) { | |||
| 150 | 154 | } | |
| 151 | 155 | ||
| 152 | 156 | func TestEntryKindOf(t *testing.T) { | |
| 157 | + | t.Parallel() | |
| 153 | 158 | tests := map[uint32]domain.EntryKind{ | |
| 154 | 159 | 0o100644: domain.EntryFile, | |
| 155 | 160 | 0o100664: domain.EntryFile, | |
| @@ -166,5 +171,6 @@ func TestEntryKindOf(t *testing.T) { | |||
| 166 | 171 | } | |
| 167 | 172 | ||
| 168 | 173 | func TestDefaultDiffLimits(t *testing.T) { | |
| 174 | + | t.Parallel() | |
| 169 | 175 | assert.Equal(t, domain.DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}, domain.DefaultDiffLimits()) | |
| 170 | 176 | } | |
M internal/domain/mergerequest_test.go +10 -0
| @@ -21,6 +21,7 @@ func oid(t *testing.T, c byte) domain.ObjectID { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | func TestDefaultRepoSettings(t *testing.T) { | |
| 24 | + | t.Parallel() | |
| 24 | 25 | s := domain.DefaultRepoSettings() | |
| 25 | 26 | assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes) | |
| 26 | 27 | assert.True(t, s.FailingChecksBlock) | |
| @@ -35,6 +36,7 @@ func TestDefaultRepoSettings(t *testing.T) { | |||
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | 38 | func TestNormalizeMergeModes(t *testing.T) { | |
| 39 | + | t.Parallel() | |
| 38 | 40 | got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash}) | |
| 39 | 41 | require.NoError(t, err) | |
| 40 | 42 | assert.Equal(t, domain.MergeModes(), got) | |
| @@ -50,6 +52,7 @@ func TestNormalizeMergeModes(t *testing.T) { | |||
| 50 | 52 | } | |
| 51 | 53 | ||
| 52 | 54 | func TestParseMergeMode(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 53 | 56 | m, err := domain.ParseMergeMode("fast-forward") | |
| 54 | 57 | require.NoError(t, err) | |
| 55 | 58 | assert.Equal(t, domain.MergeModeFastForward, m) | |
| @@ -78,6 +81,7 @@ func hunkDiff() domain.Diff { | |||
| 78 | 81 | } | |
| 79 | 82 | ||
| 80 | 83 | func TestDiffHasLine(t *testing.T) { | |
| 84 | + | t.Parallel() | |
| 81 | 85 | d := hunkDiff() | |
| 82 | 86 | tests := []struct { | |
| 83 | 87 | name string | |
| @@ -106,6 +110,7 @@ func TestDiffHasLine(t *testing.T) { | |||
| 106 | 110 | } | |
| 107 | 111 | for _, tc := range tests { | |
| 108 | 112 | t.Run(tc.name, func(t *testing.T) { | |
| 113 | + | t.Parallel() | |
| 109 | 114 | assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line)) | |
| 110 | 115 | }) | |
| 111 | 116 | } | |
| @@ -116,6 +121,7 @@ func sig(name, email string, sec int64) domain.Signature { | |||
| 116 | 121 | } | |
| 117 | 122 | ||
| 118 | 123 | func TestNewSquashCommit(t *testing.T) { | |
| 124 | + | t.Parallel() | |
| 119 | 125 | title, err := domain.ParseMRTitle("Add search") | |
| 120 | 126 | require.NoError(t, err) | |
| 121 | 127 | mr := domain.MergeRequest{Number: 7, Title: title} | |
| @@ -151,6 +157,7 @@ func TestNewSquashCommit(t *testing.T) { | |||
| 151 | 157 | } | |
| 152 | 158 | ||
| 153 | 159 | func TestSquashCommitSigned(t *testing.T) { | |
| 160 | + | t.Parallel() | |
| 154 | 161 | title, err := domain.ParseMRTitle("T") | |
| 155 | 162 | require.NoError(t, err) | |
| 156 | 163 | c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'), | |
| @@ -169,6 +176,7 @@ func TestSquashCommitSigned(t *testing.T) { | |||
| 169 | 176 | } | |
| 170 | 177 | ||
| 171 | 178 | func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) { | |
| 179 | + | t.Parallel() | |
| 172 | 180 | title, err := domain.ParseMRTitle("T") | |
| 173 | 181 | require.NoError(t, err) | |
| 174 | 182 | commits := []domain.Commit{ | |
| @@ -183,6 +191,7 @@ func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) { | |||
| 183 | 191 | } | |
| 184 | 192 | ||
| 185 | 193 | func TestSigningKeyInfoAllowedSigners(t *testing.T) { | |
| 194 | + | t.Parallel() | |
| 186 | 195 | i := domain.SigningKeyInfo{ | |
| 187 | 196 | Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"}, | |
| 188 | 197 | Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, | |
| @@ -191,6 +200,7 @@ func TestSigningKeyInfoAllowedSigners(t *testing.T) { | |||
| 191 | 200 | } | |
| 192 | 201 | ||
| 193 | 202 | func TestParseBranchName(t *testing.T) { | |
| 203 | + | t.Parallel() | |
| 194 | 204 | for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} { | |
| 195 | 205 | got, err := domain.ParseBranchName(ok) | |
| 196 | 206 | require.NoError(t, err, ok) | |
M internal/domain/name_test.go +6 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | func TestParseRepoName(t *testing.T) { | |
| 17 | + | t.Parallel() | |
| 17 | 18 | tests := []struct { | |
| 18 | 19 | in string | |
| 19 | 20 | valid bool | |
| @@ -45,6 +46,7 @@ func TestParseRepoName(t *testing.T) { | |||
| 45 | 46 | } | |
| 46 | 47 | for _, tt := range tests { | |
| 47 | 48 | t.Run(tt.in, func(t *testing.T) { | |
| 49 | + | t.Parallel() | |
| 48 | 50 | n, err := domain.ParseRepoName(tt.in) | |
| 49 | 51 | if tt.valid { | |
| 50 | 52 | require.NoError(t, err) | |
| @@ -66,6 +68,7 @@ func TestParseRepoName(t *testing.T) { | |||
| 66 | 68 | } | |
| 67 | 69 | ||
| 68 | 70 | func TestParseRepoPath(t *testing.T) { | |
| 71 | + | t.Parallel() | |
| 69 | 72 | tests := []struct { | |
| 70 | 73 | in string | |
| 71 | 74 | want string | |
| @@ -80,6 +83,7 @@ func TestParseRepoPath(t *testing.T) { | |||
| 80 | 83 | } | |
| 81 | 84 | for _, tt := range tests { | |
| 82 | 85 | t.Run(tt.in, func(t *testing.T) { | |
| 86 | + | t.Parallel() | |
| 83 | 87 | n, err := domain.ParseRepoPath(tt.in) | |
| 84 | 88 | if !tt.valid { | |
| 85 | 89 | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| @@ -92,6 +96,7 @@ func TestParseRepoPath(t *testing.T) { | |||
| 92 | 96 | } | |
| 93 | 97 | ||
| 94 | 98 | func TestParseVisibility(t *testing.T) { | |
| 99 | + | t.Parallel() | |
| 95 | 100 | for in, want := range map[string]domain.Visibility{"public": domain.VisibilityPublic, "private": domain.VisibilityPrivate} { | |
| 96 | 101 | got, err := domain.ParseVisibility(in) | |
| 97 | 102 | require.NoError(t, err) | |
| @@ -104,6 +109,7 @@ func TestParseVisibility(t *testing.T) { | |||
| 104 | 109 | } | |
| 105 | 110 | ||
| 106 | 111 | func TestPrincipal(t *testing.T) { | |
| 112 | + | t.Parallel() | |
| 107 | 113 | var zero domain.Principal | |
| 108 | 114 | assert.True(t, zero.IsAnonymous()) | |
| 109 | 115 | assert.False(t, zero.IsRoot()) | |
M internal/domain/text_test.go +6 -0
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | // textCases are the rules shared by every text; multi says whether line breaks | |
| 17 | 17 | // are allowed. | |
| 18 | 18 | func TestParseMRTitle(t *testing.T) { | |
| 19 | + | t.Parallel() | |
| 19 | 20 | tests := []struct { | |
| 20 | 21 | name string | |
| 21 | 22 | in string | |
| @@ -48,6 +49,7 @@ func TestParseMRTitle(t *testing.T) { | |||
| 48 | 49 | } | |
| 49 | 50 | for _, tc := range tests { | |
| 50 | 51 | t.Run(tc.name, func(t *testing.T) { | |
| 52 | + | t.Parallel() | |
| 51 | 53 | got, err := domain.ParseMRTitle(tc.in) | |
| 52 | 54 | if tc.reason == "" { | |
| 53 | 55 | require.NoError(t, err) | |
| @@ -64,6 +66,7 @@ func TestParseMRTitle(t *testing.T) { | |||
| 64 | 66 | } | |
| 65 | 67 | ||
| 66 | 68 | func TestParsePlainText(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 67 | 70 | tests := []struct { | |
| 68 | 71 | name string | |
| 69 | 72 | in string | |
| @@ -92,6 +95,7 @@ func TestParsePlainText(t *testing.T) { | |||
| 92 | 95 | } | |
| 93 | 96 | for _, tc := range tests { | |
| 94 | 97 | t.Run(tc.name, func(t *testing.T) { | |
| 98 | + | t.Parallel() | |
| 95 | 99 | got, err := domain.ParsePlainText(tc.in) | |
| 96 | 100 | if tc.reason == "" { | |
| 97 | 101 | require.NoError(t, err) | |
| @@ -107,6 +111,7 @@ func TestParsePlainText(t *testing.T) { | |||
| 107 | 111 | } | |
| 108 | 112 | ||
| 109 | 113 | func TestPlainTextIsBlank(t *testing.T) { | |
| 114 | + | t.Parallel() | |
| 110 | 115 | for in, blank := range map[string]bool{"": true, " \n\n ": true, "x": false, " x ": false} { | |
| 111 | 116 | got, err := domain.ParsePlainText(in) | |
| 112 | 117 | require.NoError(t, err) | |
| @@ -115,6 +120,7 @@ func TestPlainTextIsBlank(t *testing.T) { | |||
| 115 | 120 | } | |
| 116 | 121 | ||
| 117 | 122 | func TestViolations(t *testing.T) { | |
| 123 | + | t.Parallel() | |
| 118 | 124 | var v domain.Violations | |
| 119 | 125 | require.NoError(t, v.Err()) | |
| 120 | 126 | ||
M internal/domain/token_test.go +6 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func TestScopes(t *testing.T) { | |
| 16 | + | t.Parallel() | |
| 16 | 17 | all := domain.AllScopes() | |
| 17 | 18 | assert.Len(t, all, 9) | |
| 18 | 19 | for _, s := range all { | |
| @@ -27,6 +28,7 @@ func TestScopes(t *testing.T) { | |||
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | func TestHashTokenSecret(t *testing.T) { | |
| 31 | + | t.Parallel() | |
| 30 | 32 | h := domain.HashTokenSecret("ermite_abc") | |
| 31 | 33 | assert.Equal(t, h, domain.HashTokenSecret("ermite_abc")) | |
| 32 | 34 | assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd")) | |
| @@ -36,6 +38,7 @@ func TestHashTokenSecret(t *testing.T) { | |||
| 36 | 38 | } | |
| 37 | 39 | ||
| 38 | 40 | func TestTokenExpired(t *testing.T) { | |
| 41 | + | t.Parallel() | |
| 39 | 42 | now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC) | |
| 40 | 43 | assert.False(t, domain.Token{}.Expired(now), "no expiry never expires") | |
| 41 | 44 | assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now)) | |
| @@ -44,6 +47,7 @@ func TestTokenExpired(t *testing.T) { | |||
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | 49 | func TestPrincipalScopes(t *testing.T) { | |
| 50 | + | t.Parallel() | |
| 47 | 51 | a, b := mustName(t, "a"), mustName(t, "b") | |
| 48 | 52 | tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}} | |
| 49 | 53 | p := domain.TokenPrincipal(tok) | |
| @@ -84,6 +88,7 @@ func mustName(t *testing.T, s string) domain.RepoName { | |||
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | func TestOwnerPrincipal(t *testing.T) { | |
| 91 | + | t.Parallel() | |
| 87 | 92 | o := domain.Owner() | |
| 88 | 93 | assert.Equal(t, "owner", o.Label()) | |
| 89 | 94 | assert.True(t, o.IsOwner()) | |
| @@ -96,6 +101,7 @@ func TestOwnerPrincipal(t *testing.T) { | |||
| 96 | 101 | } | |
| 97 | 102 | ||
| 98 | 103 | func TestHasOwnerRights(t *testing.T) { | |
| 104 | + | t.Parallel() | |
| 99 | 105 | a := mustName(t, "a") | |
| 100 | 106 | all := domain.AllScopes() | |
| 101 | 107 | assert.True(t, domain.Root().HasOwnerRights()) | |
A internal/ports/dto.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package ports | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The parameters and results of the use case interfaces (services.go). They | |
| 12 | + | // live here so that the adapters that call the use cases and the use cases | |
| 13 | + | // themselves share them without importing each other. | |
| 14 | + | ||
| 15 | + | // RepoPage is a page of repositories ordered by name. | |
| 16 | + | type RepoPage struct { | |
| 17 | + | Repos []domain.Repo | |
| 18 | + | // NextPageToken is empty on the last page. | |
| 19 | + | NextPageToken string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // RepoView is a repository with the Git state that belongs in its summary. | |
| 23 | + | type RepoView struct { | |
| 24 | + | domain.Repo | |
| 25 | + | // DefaultBranch is the branch HEAD points to, even when it does not exist | |
| 26 | + | // yet; empty when HEAD is detached or the directory is missing. | |
| 27 | + | DefaultBranch string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // CreateRepoParams are the settings of a new repository. | |
| 31 | + | type CreateRepoParams struct { | |
| 32 | + | Name domain.RepoName | |
| 33 | + | Description string | |
| 34 | + | // Visibility defaults to private when empty. | |
| 35 | + | Visibility domain.Visibility | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // UpdateRepoParams are the changes of Update; a nil field is left alone. | |
| 39 | + | type UpdateRepoParams struct { | |
| 40 | + | Description *string | |
| 41 | + | Visibility *domain.Visibility | |
| 42 | + | DefaultBranch *string | |
| 43 | + | // AllowedMergeModes replaces the allowed merge modes; it must hold at | |
| 44 | + | // least one known mode. | |
| 45 | + | AllowedMergeModes *[]domain.MergeMode | |
| 46 | + | // FailingChecksBlock replaces the setting of that name. | |
| 47 | + | FailingChecksBlock *bool | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // TokenPage is a page of stored tokens, newest first. | |
| 51 | + | type TokenPage struct { | |
| 52 | + | Tokens []domain.Token | |
| 53 | + | // NextPageToken is empty on the last page. | |
| 54 | + | NextPageToken string | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // CreateTokenParams are the settings of a new token. | |
| 58 | + | type CreateTokenParams struct { | |
| 59 | + | Label string | |
| 60 | + | // Scopes must not be empty. | |
| 61 | + | Scopes []domain.Scope | |
| 62 | + | // Repos limits the token to these repositories; empty means every one. | |
| 63 | + | Repos []domain.RepoName | |
| 64 | + | // ExpiresAt is the zero time for a token that never expires. | |
| 65 | + | ExpiresAt time.Time | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // CreatedToken is a new token with its secret, which is shown once and never | |
| 69 | + | // stored. | |
| 70 | + | type CreatedToken struct { | |
| 71 | + | Token domain.Token | |
| 72 | + | Secret string | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // ListRefsParams selects the refs to list. | |
| 76 | + | type ListRefsParams struct { | |
| 77 | + | // Kinds restricts the result; empty means every kind. | |
| 78 | + | Kinds []domain.RefKind | |
| 79 | + | // PageSize is 0 for the default, and lowered to MaxRefPageSize. | |
| 80 | + | PageSize int | |
| 81 | + | // PageToken is the NextPageToken of the previous page, or empty. | |
| 82 | + | PageToken string | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // RefPage is a page of refs. | |
| 86 | + | type RefPage struct { | |
| 87 | + | Refs []domain.Ref | |
| 88 | + | // NextPageToken is empty on the last page. | |
| 89 | + | NextPageToken string | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // ListCommitsParams selects a page of history. | |
| 93 | + | type ListCommitsParams struct { | |
| 94 | + | // Revision is where the history starts; the zero value is HEAD. Ignored | |
| 95 | + | // when PageToken is set: the token pins the commit the first page started | |
| 96 | + | // from, so every page walks the same history even if a branch moves. | |
| 97 | + | Revision domain.Revision | |
| 98 | + | // Path limits the history to the commits that changed it. It must be the | |
| 99 | + | // same on every page of a listing. | |
| 100 | + | Path domain.TreePath | |
| 101 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |
| 102 | + | PageSize int | |
| 103 | + | // PageToken is the NextPageToken of the previous page, or empty. | |
| 104 | + | PageToken string | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // HistoryPage is a page of history, newest first. | |
| 108 | + | type HistoryPage struct { | |
| 109 | + | Commits []domain.Commit | |
| 110 | + | // NextPageToken is empty on the last page. | |
| 111 | + | NextPageToken string | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // WatchParams select the events of a watch. | |
| 115 | + | type WatchParams struct { | |
| 116 | + | // Topics are TopicHome or "repo:<name>"; empty means TopicHome. | |
| 117 | + | Topics []string | |
| 118 | + | // ResumeAfter is the id of the last event the caller saw; empty to | |
| 119 | + | // receive only new events. | |
| 120 | + | ResumeAfter string | |
| 121 | + | // Client identifies where the watch comes from, for the limit per client | |
| 122 | + | // (MaxWatchersPerClient): an opaque key such as a normalized address; the | |
| 123 | + | // web passes its visitor's. Empty only for callers with no client, which | |
| 124 | + | // count against MaxWatchers alone. | |
| 125 | + | Client string | |
| 126 | + | // Ready, when not nil, is called once the topics are valid and the watch | |
| 127 | + | // is registered: events published from then on are not missed. Nothing is | |
| 128 | + | // sent before it returns. | |
| 129 | + | Ready func() | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // WatchEvent is one item of a watch. | |
| 133 | + | type WatchEvent struct { | |
| 134 | + | // ID identifies the event, for resuming. Empty on a gap. | |
| 135 | + | ID string | |
| 136 | + | // Gap tells that events were lost: the caller must reload what it shows. | |
| 137 | + | // Event is nil then. | |
| 138 | + | Gap bool | |
| 139 | + | // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent, | |
| 140 | + | // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or | |
| 141 | + | // CommentAdded. | |
| 142 | + | Event domain.Event | |
| 143 | + | // Repo is the state of the repository at delivery, for RepoCreated and | |
| 144 | + | // RepoUpdated. | |
| 145 | + | Repo *RepoView | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // CreateMergeRequestParams describes the merge request to open. | |
| 149 | + | type CreateMergeRequestParams struct { | |
| 150 | + | Title string | |
| 151 | + | Description string | |
| 152 | + | // Source is required; Target defaults to the default branch. | |
| 153 | + | Source, Target string | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // ListMergeRequestsParams selects a page of merge requests. | |
| 157 | + | type ListMergeRequestsParams struct { | |
| 158 | + | // State restricts the result; empty means every state. | |
| 159 | + | State domain.MRState | |
| 160 | + | // PageSize is 0 for the default, and lowered to MaxPageSize. | |
| 161 | + | PageSize int | |
| 162 | + | // PageToken is the NextPageToken of the previous page, or empty. | |
| 163 | + | PageToken string | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // MergeRequestPage is a page of merge requests, highest number first. | |
| 167 | + | type MergeRequestPage struct { | |
| 168 | + | MergeRequests []domain.MergeRequest | |
| 169 | + | // NextPageToken is empty on the last page. | |
| 170 | + | NextPageToken string | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // MergeRequestDetail is a merge request with its review data. | |
| 174 | + | type MergeRequestDetail struct { | |
| 175 | + | MergeRequest domain.MergeRequest | |
| 176 | + | // Commits are the commits of the head that the target lacks, newest | |
| 177 | + | // first, at most MaxMergeRequestCommits. | |
| 178 | + | Commits []domain.Commit | |
| 179 | + | CommitsTruncated bool | |
| 180 | + | // DiffBaseID is the commit the diff compares from; zero when the source | |
| 181 | + | // branch is gone, then the diff is empty. | |
| 182 | + | DiffBaseID domain.ObjectID | |
| 183 | + | Stats domain.DiffStats | |
| 184 | + | Diff domain.Diff | |
| 185 | + | // Comments are the comments, oldest first. | |
| 186 | + | Comments []domain.Comment | |
| 187 | + | // LastReadID is the head recorded by MarkRead; zero when none. | |
| 188 | + | LastReadID domain.ObjectID | |
| 189 | + | // LastReadLost tells that since-last-read was asked for, a head was | |
| 190 | + | // recorded, but that commit is gone: the diff is against the target. | |
| 191 | + | LastReadLost bool | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // AddCommentParams describes the comment to add. | |
| 195 | + | type AddCommentParams struct { | |
| 196 | + | // Path, Line and Side designate a line of the diff; all empty for a | |
| 197 | + | // general comment. | |
| 198 | + | Path string | |
| 199 | + | Line int | |
| 200 | + | Side domain.DiffSide | |
| 201 | + | Text string | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // MergeParams describes the merge to perform. | |
| 205 | + | type MergeParams struct { | |
| 206 | + | // Mode defaults to fast-forward. | |
| 207 | + | Mode domain.MergeMode | |
| 208 | + | // HeadID is the commit the caller reviewed; the source branch must still | |
| 209 | + | // point to it. | |
| 210 | + | HeadID domain.ObjectID | |
| 211 | + | // DeleteSource deletes the source branch after the merge. | |
| 212 | + | DeleteSource bool | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // MergeResult is a merged request and what happened to its source branch. | |
| 216 | + | type MergeResult struct { | |
| 217 | + | domain.MergeRequest | |
| 218 | + | // SourceDeleted is true when DeleteSource was asked and the branch is | |
| 219 | + | // gone. | |
| 220 | + | SourceDeleted bool | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // LoginLink is a login link as the owner receives it. | |
| 224 | + | type LoginLink struct { | |
| 225 | + | // URL holds the secret: show it once, never log it. | |
| 226 | + | URL string | |
| 227 | + | ExpiresAt time.Time | |
| 228 | + | } | |
A internal/ports/mocks/AuthService.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewAuthService creates a new instance of AuthService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewAuthService(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *AuthService { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &AuthService{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // AuthService is an autogenerated mock type for the AuthService type | |
| 38 | + | type AuthService struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type AuthService_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *AuthService) EXPECT() *AuthService_Expecter { | |
| 47 | + | return &AuthService_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Authenticate provides a mock function for the type AuthService | |
| 51 | + | func (_mock *AuthService) Authenticate(ctx context.Context, secret string) (domain.Principal, error) { | |
| 52 | + | ret := _mock.Called(ctx, secret) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Authenticate") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.Principal | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, error)); ok { | |
| 61 | + | return returnFunc(ctx, secret) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |
| 64 | + | r0 = returnFunc(ctx, secret) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.Principal) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, secret) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // AuthService_Authenticate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Authenticate' | |
| 77 | + | type AuthService_Authenticate_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Authenticate is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - secret string | |
| 84 | + | func (_e *AuthService_Expecter) Authenticate(ctx any, secret any) *AuthService_Authenticate_Call { | |
| 85 | + | return &AuthService_Authenticate_Call{Call: _e.mock.On("Authenticate", ctx, secret)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *AuthService_Authenticate_Call) Run(run func(ctx context.Context, secret string)) *AuthService_Authenticate_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | var arg1 string | |
| 95 | + | if args[1] != nil { | |
| 96 | + | arg1 = args[1].(string) | |
| 97 | + | } | |
| 98 | + | run( | |
| 99 | + | arg0, | |
| 100 | + | arg1, | |
| 101 | + | ) | |
| 102 | + | }) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *AuthService_Authenticate_Call) Return(principal domain.Principal, err error) *AuthService_Authenticate_Call { | |
| 107 | + | _c.Call.Return(principal, err) | |
| 108 | + | return _c | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (_c *AuthService_Authenticate_Call) RunAndReturn(run func(ctx context.Context, secret string) (domain.Principal, error)) *AuthService_Authenticate_Call { | |
| 112 | + | _c.Call.Return(run) | |
| 113 | + | return _c | |
| 114 | + | } | |
A internal/ports/mocks/BrowseService.go +622 -0
| @@ -0,0 +1,622 @@ | |||
| 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 | + | "io" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | "bnema.dev/ermite/internal/ports" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewBrowseService creates a new instance of BrowseService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewBrowseService(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *BrowseService { | |
| 22 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 23 | + | helper.Helper() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | mock := &BrowseService{} | |
| 27 | + | mock.Mock.Test(t) | |
| 28 | + | ||
| 29 | + | t.Cleanup(func() { | |
| 30 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 31 | + | helper.Helper() | |
| 32 | + | } | |
| 33 | + | mock.AssertExpectations(t) | |
| 34 | + | }) | |
| 35 | + | ||
| 36 | + | return mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // BrowseService is an autogenerated mock type for the BrowseService type | |
| 40 | + | type BrowseService struct { | |
| 41 | + | mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type BrowseService_Expecter struct { | |
| 45 | + | mock *mock.Mock | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (_m *BrowseService) EXPECT() *BrowseService_Expecter { | |
| 49 | + | return &BrowseService_Expecter{mock: &_m.Mock} | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // GetCommit provides a mock function for the type BrowseService | |
| 53 | + | func (_mock *BrowseService) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) { | |
| 54 | + | ret := _mock.Called(ctx, p, repo, rev) | |
| 55 | + | ||
| 56 | + | if len(ret) == 0 { | |
| 57 | + | panic("no return value specified for GetCommit") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var r0 domain.CommitDetail | |
| 61 | + | var r1 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.CommitDetail, error)); ok { | |
| 63 | + | return returnFunc(ctx, p, repo, rev) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.CommitDetail); ok { | |
| 66 | + | r0 = returnFunc(ctx, p, repo, rev) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(domain.CommitDetail) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |
| 71 | + | r1 = returnFunc(ctx, p, repo, rev) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Error(1) | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // BrowseService_GetCommit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCommit' | |
| 79 | + | type BrowseService_GetCommit_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // GetCommit is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - p domain.Principal | |
| 86 | + | // - repo domain.RepoName | |
| 87 | + | // - rev domain.Revision | |
| 88 | + | func (_e *BrowseService_Expecter) GetCommit(ctx any, p any, repo any, rev any) *BrowseService_GetCommit_Call { | |
| 89 | + | return &BrowseService_GetCommit_Call{Call: _e.mock.On("GetCommit", ctx, p, repo, rev)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *BrowseService_GetCommit_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_GetCommit_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.Principal | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.Principal) | |
| 101 | + | } | |
| 102 | + | var arg2 domain.RepoName | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(domain.RepoName) | |
| 105 | + | } | |
| 106 | + | var arg3 domain.Revision | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(domain.Revision) | |
| 109 | + | } | |
| 110 | + | run( | |
| 111 | + | arg0, | |
| 112 | + | arg1, | |
| 113 | + | arg2, | |
| 114 | + | arg3, | |
| 115 | + | ) | |
| 116 | + | }) | |
| 117 | + | return _c | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (_c *BrowseService_GetCommit_Call) Return(commitDetail domain.CommitDetail, err error) *BrowseService_GetCommit_Call { | |
| 121 | + | _c.Call.Return(commitDetail, err) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *BrowseService_GetCommit_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error)) *BrowseService_GetCommit_Call { | |
| 126 | + | _c.Call.Return(run) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // GetTree provides a mock function for the type BrowseService | |
| 131 | + | func (_mock *BrowseService) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) { | |
| 132 | + | ret := _mock.Called(ctx, p, repo, rev, path, includeLastCommit) | |
| 133 | + | ||
| 134 | + | if len(ret) == 0 { | |
| 135 | + | panic("no return value specified for GetTree") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | var r0 domain.Tree | |
| 139 | + | var r1 error | |
| 140 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) (domain.Tree, error)); ok { | |
| 141 | + | return returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |
| 142 | + | } | |
| 143 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) domain.Tree); ok { | |
| 144 | + | r0 = returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |
| 145 | + | } else { | |
| 146 | + | r0 = ret.Get(0).(domain.Tree) | |
| 147 | + | } | |
| 148 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath, bool) error); ok { | |
| 149 | + | r1 = returnFunc(ctx, p, repo, rev, path, includeLastCommit) | |
| 150 | + | } else { | |
| 151 | + | r1 = ret.Error(1) | |
| 152 | + | } | |
| 153 | + | return r0, r1 | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // BrowseService_GetTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTree' | |
| 157 | + | type BrowseService_GetTree_Call struct { | |
| 158 | + | *mock.Call | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // GetTree is a helper method to define mock.On call | |
| 162 | + | // - ctx context.Context | |
| 163 | + | // - p domain.Principal | |
| 164 | + | // - repo domain.RepoName | |
| 165 | + | // - rev domain.Revision | |
| 166 | + | // - path domain.TreePath | |
| 167 | + | // - includeLastCommit bool | |
| 168 | + | func (_e *BrowseService_Expecter) GetTree(ctx any, p any, repo any, rev any, path any, includeLastCommit any) *BrowseService_GetTree_Call { | |
| 169 | + | return &BrowseService_GetTree_Call{Call: _e.mock.On("GetTree", ctx, p, repo, rev, path, includeLastCommit)} | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *BrowseService_GetTree_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool)) *BrowseService_GetTree_Call { | |
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 174 | + | var arg0 context.Context | |
| 175 | + | if args[0] != nil { | |
| 176 | + | arg0 = args[0].(context.Context) | |
| 177 | + | } | |
| 178 | + | var arg1 domain.Principal | |
| 179 | + | if args[1] != nil { | |
| 180 | + | arg1 = args[1].(domain.Principal) | |
| 181 | + | } | |
| 182 | + | var arg2 domain.RepoName | |
| 183 | + | if args[2] != nil { | |
| 184 | + | arg2 = args[2].(domain.RepoName) | |
| 185 | + | } | |
| 186 | + | var arg3 domain.Revision | |
| 187 | + | if args[3] != nil { | |
| 188 | + | arg3 = args[3].(domain.Revision) | |
| 189 | + | } | |
| 190 | + | var arg4 domain.TreePath | |
| 191 | + | if args[4] != nil { | |
| 192 | + | arg4 = args[4].(domain.TreePath) | |
| 193 | + | } | |
| 194 | + | var arg5 bool | |
| 195 | + | if args[5] != nil { | |
| 196 | + | arg5 = args[5].(bool) | |
| 197 | + | } | |
| 198 | + | run( | |
| 199 | + | arg0, | |
| 200 | + | arg1, | |
| 201 | + | arg2, | |
| 202 | + | arg3, | |
| 203 | + | arg4, | |
| 204 | + | arg5, | |
| 205 | + | ) | |
| 206 | + | }) | |
| 207 | + | return _c | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (_c *BrowseService_GetTree_Call) Return(tree domain.Tree, err error) *BrowseService_GetTree_Call { | |
| 211 | + | _c.Call.Return(tree, err) | |
| 212 | + | return _c | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (_c *BrowseService_GetTree_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)) *BrowseService_GetTree_Call { | |
| 216 | + | _c.Call.Return(run) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // ListCommits provides a mock function for the type BrowseService | |
| 221 | + | func (_mock *BrowseService) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) { | |
| 222 | + | ret := _mock.Called(ctx, p, repo, params) | |
| 223 | + | ||
| 224 | + | if len(ret) == 0 { | |
| 225 | + | panic("no return value specified for ListCommits") | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | var r0 ports.HistoryPage | |
| 229 | + | var r1 error | |
| 230 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error)); ok { | |
| 231 | + | return returnFunc(ctx, p, repo, params) | |
| 232 | + | } | |
| 233 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) ports.HistoryPage); ok { | |
| 234 | + | r0 = returnFunc(ctx, p, repo, params) | |
| 235 | + | } else { | |
| 236 | + | r0 = ret.Get(0).(ports.HistoryPage) | |
| 237 | + | } | |
| 238 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) error); ok { | |
| 239 | + | r1 = returnFunc(ctx, p, repo, params) | |
| 240 | + | } else { | |
| 241 | + | r1 = ret.Error(1) | |
| 242 | + | } | |
| 243 | + | return r0, r1 | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // BrowseService_ListCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCommits' | |
| 247 | + | type BrowseService_ListCommits_Call struct { | |
| 248 | + | *mock.Call | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // ListCommits is a helper method to define mock.On call | |
| 252 | + | // - ctx context.Context | |
| 253 | + | // - p domain.Principal | |
| 254 | + | // - repo domain.RepoName | |
| 255 | + | // - params ports.ListCommitsParams | |
| 256 | + | func (_e *BrowseService_Expecter) ListCommits(ctx any, p any, repo any, params any) *BrowseService_ListCommits_Call { | |
| 257 | + | return &BrowseService_ListCommits_Call{Call: _e.mock.On("ListCommits", ctx, p, repo, params)} | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func (_c *BrowseService_ListCommits_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams)) *BrowseService_ListCommits_Call { | |
| 261 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 262 | + | var arg0 context.Context | |
| 263 | + | if args[0] != nil { | |
| 264 | + | arg0 = args[0].(context.Context) | |
| 265 | + | } | |
| 266 | + | var arg1 domain.Principal | |
| 267 | + | if args[1] != nil { | |
| 268 | + | arg1 = args[1].(domain.Principal) | |
| 269 | + | } | |
| 270 | + | var arg2 domain.RepoName | |
| 271 | + | if args[2] != nil { | |
| 272 | + | arg2 = args[2].(domain.RepoName) | |
| 273 | + | } | |
| 274 | + | var arg3 ports.ListCommitsParams | |
| 275 | + | if args[3] != nil { | |
| 276 | + | arg3 = args[3].(ports.ListCommitsParams) | |
| 277 | + | } | |
| 278 | + | run( | |
| 279 | + | arg0, | |
| 280 | + | arg1, | |
| 281 | + | arg2, | |
| 282 | + | arg3, | |
| 283 | + | ) | |
| 284 | + | }) | |
| 285 | + | return _c | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (_c *BrowseService_ListCommits_Call) Return(historyPage ports.HistoryPage, err error) *BrowseService_ListCommits_Call { | |
| 289 | + | _c.Call.Return(historyPage, err) | |
| 290 | + | return _c | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | func (_c *BrowseService_ListCommits_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error)) *BrowseService_ListCommits_Call { | |
| 294 | + | _c.Call.Return(run) | |
| 295 | + | return _c | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // ListPaths provides a mock function for the type BrowseService | |
| 299 | + | func (_mock *BrowseService) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) { | |
| 300 | + | ret := _mock.Called(ctx, p, repo, rev) | |
| 301 | + | ||
| 302 | + | if len(ret) == 0 { | |
| 303 | + | panic("no return value specified for ListPaths") | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | var r0 domain.PathList | |
| 307 | + | var r1 error | |
| 308 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.PathList, error)); ok { | |
| 309 | + | return returnFunc(ctx, p, repo, rev) | |
| 310 | + | } | |
| 311 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.PathList); ok { | |
| 312 | + | r0 = returnFunc(ctx, p, repo, rev) | |
| 313 | + | } else { | |
| 314 | + | r0 = ret.Get(0).(domain.PathList) | |
| 315 | + | } | |
| 316 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |
| 317 | + | r1 = returnFunc(ctx, p, repo, rev) | |
| 318 | + | } else { | |
| 319 | + | r1 = ret.Error(1) | |
| 320 | + | } | |
| 321 | + | return r0, r1 | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // BrowseService_ListPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPaths' | |
| 325 | + | type BrowseService_ListPaths_Call struct { | |
| 326 | + | *mock.Call | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // ListPaths is a helper method to define mock.On call | |
| 330 | + | // - ctx context.Context | |
| 331 | + | // - p domain.Principal | |
| 332 | + | // - repo domain.RepoName | |
| 333 | + | // - rev domain.Revision | |
| 334 | + | func (_e *BrowseService_Expecter) ListPaths(ctx any, p any, repo any, rev any) *BrowseService_ListPaths_Call { | |
| 335 | + | return &BrowseService_ListPaths_Call{Call: _e.mock.On("ListPaths", ctx, p, repo, rev)} | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func (_c *BrowseService_ListPaths_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_ListPaths_Call { | |
| 339 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 340 | + | var arg0 context.Context | |
| 341 | + | if args[0] != nil { | |
| 342 | + | arg0 = args[0].(context.Context) | |
| 343 | + | } | |
| 344 | + | var arg1 domain.Principal | |
| 345 | + | if args[1] != nil { | |
| 346 | + | arg1 = args[1].(domain.Principal) | |
| 347 | + | } | |
| 348 | + | var arg2 domain.RepoName | |
| 349 | + | if args[2] != nil { | |
| 350 | + | arg2 = args[2].(domain.RepoName) | |
| 351 | + | } | |
| 352 | + | var arg3 domain.Revision | |
| 353 | + | if args[3] != nil { | |
| 354 | + | arg3 = args[3].(domain.Revision) | |
| 355 | + | } | |
| 356 | + | run( | |
| 357 | + | arg0, | |
| 358 | + | arg1, | |
| 359 | + | arg2, | |
| 360 | + | arg3, | |
| 361 | + | ) | |
| 362 | + | }) | |
| 363 | + | return _c | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func (_c *BrowseService_ListPaths_Call) Return(pathList domain.PathList, err error) *BrowseService_ListPaths_Call { | |
| 367 | + | _c.Call.Return(pathList, err) | |
| 368 | + | return _c | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | func (_c *BrowseService_ListPaths_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error)) *BrowseService_ListPaths_Call { | |
| 372 | + | _c.Call.Return(run) | |
| 373 | + | return _c | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // ListRefs provides a mock function for the type BrowseService | |
| 377 | + | func (_mock *BrowseService) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) { | |
| 378 | + | ret := _mock.Called(ctx, p, repo, params) | |
| 379 | + | ||
| 380 | + | if len(ret) == 0 { | |
| 381 | + | panic("no return value specified for ListRefs") | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | var r0 ports.RefPage | |
| 385 | + | var r1 error | |
| 386 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) (ports.RefPage, error)); ok { | |
| 387 | + | return returnFunc(ctx, p, repo, params) | |
| 388 | + | } | |
| 389 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) ports.RefPage); ok { | |
| 390 | + | r0 = returnFunc(ctx, p, repo, params) | |
| 391 | + | } else { | |
| 392 | + | r0 = ret.Get(0).(ports.RefPage) | |
| 393 | + | } | |
| 394 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRefsParams) error); ok { | |
| 395 | + | r1 = returnFunc(ctx, p, repo, params) | |
| 396 | + | } else { | |
| 397 | + | r1 = ret.Error(1) | |
| 398 | + | } | |
| 399 | + | return r0, r1 | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // BrowseService_ListRefs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRefs' | |
| 403 | + | type BrowseService_ListRefs_Call struct { | |
| 404 | + | *mock.Call | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // ListRefs is a helper method to define mock.On call | |
| 408 | + | // - ctx context.Context | |
| 409 | + | // - p domain.Principal | |
| 410 | + | // - repo domain.RepoName | |
| 411 | + | // - params ports.ListRefsParams | |
| 412 | + | func (_e *BrowseService_Expecter) ListRefs(ctx any, p any, repo any, params any) *BrowseService_ListRefs_Call { | |
| 413 | + | return &BrowseService_ListRefs_Call{Call: _e.mock.On("ListRefs", ctx, p, repo, params)} | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (_c *BrowseService_ListRefs_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams)) *BrowseService_ListRefs_Call { | |
| 417 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 418 | + | var arg0 context.Context | |
| 419 | + | if args[0] != nil { | |
| 420 | + | arg0 = args[0].(context.Context) | |
| 421 | + | } | |
| 422 | + | var arg1 domain.Principal | |
| 423 | + | if args[1] != nil { | |
| 424 | + | arg1 = args[1].(domain.Principal) | |
| 425 | + | } | |
| 426 | + | var arg2 domain.RepoName | |
| 427 | + | if args[2] != nil { | |
| 428 | + | arg2 = args[2].(domain.RepoName) | |
| 429 | + | } | |
| 430 | + | var arg3 ports.ListRefsParams | |
| 431 | + | if args[3] != nil { | |
| 432 | + | arg3 = args[3].(ports.ListRefsParams) | |
| 433 | + | } | |
| 434 | + | run( | |
| 435 | + | arg0, | |
| 436 | + | arg1, | |
| 437 | + | arg2, | |
| 438 | + | arg3, | |
| 439 | + | ) | |
| 440 | + | }) | |
| 441 | + | return _c | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | func (_c *BrowseService_ListRefs_Call) Return(refPage ports.RefPage, err error) *BrowseService_ListRefs_Call { | |
| 445 | + | _c.Call.Return(refPage, err) | |
| 446 | + | return _c | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func (_c *BrowseService_ListRefs_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error)) *BrowseService_ListRefs_Call { | |
| 450 | + | _c.Call.Return(run) | |
| 451 | + | return _c | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // OpenBlob provides a mock function for the type BrowseService | |
| 455 | + | func (_mock *BrowseService) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) { | |
| 456 | + | ret := _mock.Called(ctx, p, repo, rev, path) | |
| 457 | + | ||
| 458 | + | if len(ret) == 0 { | |
| 459 | + | panic("no return value specified for OpenBlob") | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | var r0 domain.BlobInfo | |
| 463 | + | var r1 io.ReadCloser | |
| 464 | + | var r2 error | |
| 465 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)); ok { | |
| 466 | + | return returnFunc(ctx, p, repo, rev, path) | |
| 467 | + | } | |
| 468 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) domain.BlobInfo); ok { | |
| 469 | + | r0 = returnFunc(ctx, p, repo, rev, path) | |
| 470 | + | } else { | |
| 471 | + | r0 = ret.Get(0).(domain.BlobInfo) | |
| 472 | + | } | |
| 473 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) io.ReadCloser); ok { | |
| 474 | + | r1 = returnFunc(ctx, p, repo, rev, path) | |
| 475 | + | } else { | |
| 476 | + | if ret.Get(1) != nil { | |
| 477 | + | r1 = ret.Get(1).(io.ReadCloser) | |
| 478 | + | } | |
| 479 | + | } | |
| 480 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision, domain.TreePath) error); ok { | |
| 481 | + | r2 = returnFunc(ctx, p, repo, rev, path) | |
| 482 | + | } else { | |
| 483 | + | r2 = ret.Error(2) | |
| 484 | + | } | |
| 485 | + | return r0, r1, r2 | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // BrowseService_OpenBlob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OpenBlob' | |
| 489 | + | type BrowseService_OpenBlob_Call struct { | |
| 490 | + | *mock.Call | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | // OpenBlob is a helper method to define mock.On call | |
| 494 | + | // - ctx context.Context | |
| 495 | + | // - p domain.Principal | |
| 496 | + | // - repo domain.RepoName | |
| 497 | + | // - rev domain.Revision | |
| 498 | + | // - path domain.TreePath | |
| 499 | + | func (_e *BrowseService_Expecter) OpenBlob(ctx any, p any, repo any, rev any, path any) *BrowseService_OpenBlob_Call { | |
| 500 | + | return &BrowseService_OpenBlob_Call{Call: _e.mock.On("OpenBlob", ctx, p, repo, rev, path)} | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func (_c *BrowseService_OpenBlob_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath)) *BrowseService_OpenBlob_Call { | |
| 504 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 505 | + | var arg0 context.Context | |
| 506 | + | if args[0] != nil { | |
| 507 | + | arg0 = args[0].(context.Context) | |
| 508 | + | } | |
| 509 | + | var arg1 domain.Principal | |
| 510 | + | if args[1] != nil { | |
| 511 | + | arg1 = args[1].(domain.Principal) | |
| 512 | + | } | |
| 513 | + | var arg2 domain.RepoName | |
| 514 | + | if args[2] != nil { | |
| 515 | + | arg2 = args[2].(domain.RepoName) | |
| 516 | + | } | |
| 517 | + | var arg3 domain.Revision | |
| 518 | + | if args[3] != nil { | |
| 519 | + | arg3 = args[3].(domain.Revision) | |
| 520 | + | } | |
| 521 | + | var arg4 domain.TreePath | |
| 522 | + | if args[4] != nil { | |
| 523 | + | arg4 = args[4].(domain.TreePath) | |
| 524 | + | } | |
| 525 | + | run( | |
| 526 | + | arg0, | |
| 527 | + | arg1, | |
| 528 | + | arg2, | |
| 529 | + | arg3, | |
| 530 | + | arg4, | |
| 531 | + | ) | |
| 532 | + | }) | |
| 533 | + | return _c | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | func (_c *BrowseService_OpenBlob_Call) Return(blobInfo domain.BlobInfo, readCloser io.ReadCloser, err error) *BrowseService_OpenBlob_Call { | |
| 537 | + | _c.Call.Return(blobInfo, readCloser, err) | |
| 538 | + | return _c | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | func (_c *BrowseService_OpenBlob_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)) *BrowseService_OpenBlob_Call { | |
| 542 | + | _c.Call.Return(run) | |
| 543 | + | return _c | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | // ResolveRevision provides a mock function for the type BrowseService | |
| 547 | + | func (_mock *BrowseService) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 548 | + | ret := _mock.Called(ctx, p, repo, rev) | |
| 549 | + | ||
| 550 | + | if len(ret) == 0 { | |
| 551 | + | panic("no return value specified for ResolveRevision") | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | var r0 domain.ResolvedRevision | |
| 555 | + | var r1 error | |
| 556 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) (domain.ResolvedRevision, error)); ok { | |
| 557 | + | return returnFunc(ctx, p, repo, rev) | |
| 558 | + | } | |
| 559 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) domain.ResolvedRevision); ok { | |
| 560 | + | r0 = returnFunc(ctx, p, repo, rev) | |
| 561 | + | } else { | |
| 562 | + | r0 = ret.Get(0).(domain.ResolvedRevision) | |
| 563 | + | } | |
| 564 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.Revision) error); ok { | |
| 565 | + | r1 = returnFunc(ctx, p, repo, rev) | |
| 566 | + | } else { | |
| 567 | + | r1 = ret.Error(1) | |
| 568 | + | } | |
| 569 | + | return r0, r1 | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | // BrowseService_ResolveRevision_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveRevision' | |
| 573 | + | type BrowseService_ResolveRevision_Call struct { | |
| 574 | + | *mock.Call | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // ResolveRevision is a helper method to define mock.On call | |
| 578 | + | // - ctx context.Context | |
| 579 | + | // - p domain.Principal | |
| 580 | + | // - repo domain.RepoName | |
| 581 | + | // - rev domain.Revision | |
| 582 | + | func (_e *BrowseService_Expecter) ResolveRevision(ctx any, p any, repo any, rev any) *BrowseService_ResolveRevision_Call { | |
| 583 | + | return &BrowseService_ResolveRevision_Call{Call: _e.mock.On("ResolveRevision", ctx, p, repo, rev)} | |
| 584 | + | } | |
| 585 | + | ||
| 586 | + | func (_c *BrowseService_ResolveRevision_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision)) *BrowseService_ResolveRevision_Call { | |
| 587 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 588 | + | var arg0 context.Context | |
| 589 | + | if args[0] != nil { | |
| 590 | + | arg0 = args[0].(context.Context) | |
| 591 | + | } | |
| 592 | + | var arg1 domain.Principal | |
| 593 | + | if args[1] != nil { | |
| 594 | + | arg1 = args[1].(domain.Principal) | |
| 595 | + | } | |
| 596 | + | var arg2 domain.RepoName | |
| 597 | + | if args[2] != nil { | |
| 598 | + | arg2 = args[2].(domain.RepoName) | |
| 599 | + | } | |
| 600 | + | var arg3 domain.Revision | |
| 601 | + | if args[3] != nil { | |
| 602 | + | arg3 = args[3].(domain.Revision) | |
| 603 | + | } | |
| 604 | + | run( | |
| 605 | + | arg0, | |
| 606 | + | arg1, | |
| 607 | + | arg2, | |
| 608 | + | arg3, | |
| 609 | + | ) | |
| 610 | + | }) | |
| 611 | + | return _c | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | func (_c *BrowseService_ResolveRevision_Call) Return(resolvedRevision domain.ResolvedRevision, err error) *BrowseService_ResolveRevision_Call { | |
| 615 | + | _c.Call.Return(resolvedRevision, err) | |
| 616 | + | return _c | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | func (_c *BrowseService_ResolveRevision_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)) *BrowseService_ResolveRevision_Call { | |
| 620 | + | _c.Call.Return(run) | |
| 621 | + | return _c | |
| 622 | + | } | |
A internal/ports/mocks/EventService.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 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 | + | // NewEventService creates a new instance of EventService. 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 NewEventService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *EventService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &EventService{} | |
| 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 | + | // EventService is an autogenerated mock type for the EventService type | |
| 39 | + | type EventService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type EventService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *EventService) EXPECT() *EventService_Expecter { | |
| 48 | + | return &EventService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Watch provides a mock function for the type EventService | |
| 52 | + | func (_mock *EventService) Watch(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error { | |
| 53 | + | ret := _mock.Called(ctx, p, params, send) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Watch") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.WatchParams, func(ports.WatchEvent) error) error); ok { | |
| 61 | + | r0 = returnFunc(ctx, p, params, send) | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Error(0) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // EventService_Watch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Watch' | |
| 69 | + | type EventService_Watch_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Watch is a helper method to define mock.On call | |
| 74 | + | // - ctx context.Context | |
| 75 | + | // - p domain.Principal | |
| 76 | + | // - params ports.WatchParams | |
| 77 | + | // - send func(ports.WatchEvent) error | |
| 78 | + | func (_e *EventService_Expecter) Watch(ctx any, p any, params any, send any) *EventService_Watch_Call { | |
| 79 | + | return &EventService_Watch_Call{Call: _e.mock.On("Watch", ctx, p, params, send)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *EventService_Watch_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error)) *EventService_Watch_Call { | |
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | + | var arg0 context.Context | |
| 85 | + | if args[0] != nil { | |
| 86 | + | arg0 = args[0].(context.Context) | |
| 87 | + | } | |
| 88 | + | var arg1 domain.Principal | |
| 89 | + | if args[1] != nil { | |
| 90 | + | arg1 = args[1].(domain.Principal) | |
| 91 | + | } | |
| 92 | + | var arg2 ports.WatchParams | |
| 93 | + | if args[2] != nil { | |
| 94 | + | arg2 = args[2].(ports.WatchParams) | |
| 95 | + | } | |
| 96 | + | var arg3 func(ports.WatchEvent) error | |
| 97 | + | if args[3] != nil { | |
| 98 | + | arg3 = args[3].(func(ports.WatchEvent) error) | |
| 99 | + | } | |
| 100 | + | run( | |
| 101 | + | arg0, | |
| 102 | + | arg1, | |
| 103 | + | arg2, | |
| 104 | + | arg3, | |
| 105 | + | ) | |
| 106 | + | }) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (_c *EventService_Watch_Call) Return(err error) *EventService_Watch_Call { | |
| 111 | + | _c.Call.Return(err) | |
| 112 | + | return _c | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (_c *EventService_Watch_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.WatchParams, send func(ports.WatchEvent) error) error) *EventService_Watch_Call { | |
| 116 | + | _c.Call.Return(run) | |
| 117 | + | return _c | |
| 118 | + | } | |
A internal/ports/mocks/FailureLimiter.go +160 -0
| @@ -0,0 +1,160 @@ | |||
| 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 | + | "time" | |
| 10 | + | ||
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewFailureLimiter creates a new instance of FailureLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewFailureLimiter(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *FailureLimiter { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &FailureLimiter{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // FailureLimiter is an autogenerated mock type for the FailureLimiter type | |
| 38 | + | type FailureLimiter struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type FailureLimiter_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *FailureLimiter) EXPECT() *FailureLimiter_Expecter { | |
| 47 | + | return &FailureLimiter_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Blocked provides a mock function for the type FailureLimiter | |
| 51 | + | func (_mock *FailureLimiter) Blocked(ctx context.Context, addr string) (bool, time.Duration) { | |
| 52 | + | ret := _mock.Called(ctx, addr) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Blocked") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 bool | |
| 59 | + | var r1 time.Duration | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, time.Duration)); ok { | |
| 61 | + | return returnFunc(ctx, addr) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { | |
| 64 | + | r0 = returnFunc(ctx, addr) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(bool) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok { | |
| 69 | + | r1 = returnFunc(ctx, addr) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Get(1).(time.Duration) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // FailureLimiter_Blocked_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Blocked' | |
| 77 | + | type FailureLimiter_Blocked_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Blocked is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - addr string | |
| 84 | + | func (_e *FailureLimiter_Expecter) Blocked(ctx any, addr any) *FailureLimiter_Blocked_Call { | |
| 85 | + | return &FailureLimiter_Blocked_Call{Call: _e.mock.On("Blocked", ctx, addr)} | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (_c *FailureLimiter_Blocked_Call) Run(run func(ctx context.Context, addr string)) *FailureLimiter_Blocked_Call { | |
| 89 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 90 | + | var arg0 context.Context | |
| 91 | + | if args[0] != nil { | |
| 92 | + | arg0 = args[0].(context.Context) | |
| 93 | + | } | |
| 94 | + | var arg1 string | |
| 95 | + | if args[1] != nil { | |
| 96 | + | arg1 = args[1].(string) | |
| 97 | + | } | |
| 98 | + | run( | |
| 99 | + | arg0, | |
| 100 | + | arg1, | |
| 101 | + | ) | |
| 102 | + | }) | |
| 103 | + | return _c | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (_c *FailureLimiter_Blocked_Call) Return(b bool, duration time.Duration) *FailureLimiter_Blocked_Call { | |
| 107 | + | _c.Call.Return(b, duration) | |
| 108 | + | return _c | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (_c *FailureLimiter_Blocked_Call) RunAndReturn(run func(ctx context.Context, addr string) (bool, time.Duration)) *FailureLimiter_Blocked_Call { | |
| 112 | + | _c.Call.Return(run) | |
| 113 | + | return _c | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Fail provides a mock function for the type FailureLimiter | |
| 117 | + | func (_mock *FailureLimiter) Fail(ctx context.Context, addr string) { | |
| 118 | + | _mock.Called(ctx, addr) | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // FailureLimiter_Fail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Fail' | |
| 123 | + | type FailureLimiter_Fail_Call struct { | |
| 124 | + | *mock.Call | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // Fail is a helper method to define mock.On call | |
| 128 | + | // - ctx context.Context | |
| 129 | + | // - addr string | |
| 130 | + | func (_e *FailureLimiter_Expecter) Fail(ctx any, addr any) *FailureLimiter_Fail_Call { | |
| 131 | + | return &FailureLimiter_Fail_Call{Call: _e.mock.On("Fail", ctx, addr)} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (_c *FailureLimiter_Fail_Call) Run(run func(ctx context.Context, addr string)) *FailureLimiter_Fail_Call { | |
| 135 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 136 | + | var arg0 context.Context | |
| 137 | + | if args[0] != nil { | |
| 138 | + | arg0 = args[0].(context.Context) | |
| 139 | + | } | |
| 140 | + | var arg1 string | |
| 141 | + | if args[1] != nil { | |
| 142 | + | arg1 = args[1].(string) | |
| 143 | + | } | |
| 144 | + | run( | |
| 145 | + | arg0, | |
| 146 | + | arg1, | |
| 147 | + | ) | |
| 148 | + | }) | |
| 149 | + | return _c | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (_c *FailureLimiter_Fail_Call) Return() *FailureLimiter_Fail_Call { | |
| 153 | + | _c.Call.Return() | |
| 154 | + | return _c | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (_c *FailureLimiter_Fail_Call) RunAndReturn(run func(ctx context.Context, addr string)) *FailureLimiter_Fail_Call { | |
| 158 | + | _c.Run(run) | |
| 159 | + | return _c | |
| 160 | + | } | |
A internal/ports/mocks/HookTokens.go +166 -0
| @@ -0,0 +1,166 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewHookTokens creates a new instance of HookTokens. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewHookTokens(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *HookTokens { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &HookTokens{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // HookTokens is an autogenerated mock type for the HookTokens type | |
| 38 | + | type HookTokens struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type HookTokens_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *HookTokens) EXPECT() *HookTokens_Expecter { | |
| 47 | + | return &HookTokens_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Register provides a mock function for the type HookTokens | |
| 51 | + | func (_mock *HookTokens) Register(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func()) { | |
| 52 | + | ret := _mock.Called(ctx, repo, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Register") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 string | |
| 59 | + | var r1 func() | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) (string, func())); ok { | |
| 61 | + | return returnFunc(ctx, repo, p) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.Principal) string); ok { | |
| 64 | + | r0 = returnFunc(ctx, repo, p) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(string) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.Principal) func()); ok { | |
| 69 | + | r1 = returnFunc(ctx, repo, p) | |
| 70 | + | } else { | |
| 71 | + | if ret.Get(1) != nil { | |
| 72 | + | r1 = ret.Get(1).(func()) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return r0, r1 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // HookTokens_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' | |
| 79 | + | type HookTokens_Register_Call struct { | |
| 80 | + | *mock.Call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Register is a helper method to define mock.On call | |
| 84 | + | // - ctx context.Context | |
| 85 | + | // - repo domain.RepoName | |
| 86 | + | // - p domain.Principal | |
| 87 | + | func (_e *HookTokens_Expecter) Register(ctx any, repo any, p any) *HookTokens_Register_Call { | |
| 88 | + | return &HookTokens_Register_Call{Call: _e.mock.On("Register", ctx, repo, p)} | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *HookTokens_Register_Call) Run(run func(ctx context.Context, repo domain.RepoName, p domain.Principal)) *HookTokens_Register_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.Principal | |
| 102 | + | if args[2] != nil { | |
| 103 | + | arg2 = args[2].(domain.Principal) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | arg2, | |
| 109 | + | ) | |
| 110 | + | }) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *HookTokens_Register_Call) Return(token string, release func()) *HookTokens_Register_Call { | |
| 115 | + | _c.Call.Return(token, release) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *HookTokens_Register_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, p domain.Principal) (string, func())) *HookTokens_Register_Call { | |
| 120 | + | _c.Call.Return(run) | |
| 121 | + | return _c | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Socket provides a mock function for the type HookTokens | |
| 125 | + | func (_mock *HookTokens) Socket() string { | |
| 126 | + | ret := _mock.Called() | |
| 127 | + | ||
| 128 | + | if len(ret) == 0 { | |
| 129 | + | panic("no return value specified for Socket") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | var r0 string | |
| 133 | + | if returnFunc, ok := ret.Get(0).(func() string); ok { | |
| 134 | + | r0 = returnFunc() | |
| 135 | + | } else { | |
| 136 | + | r0 = ret.Get(0).(string) | |
| 137 | + | } | |
| 138 | + | return r0 | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // HookTokens_Socket_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Socket' | |
| 142 | + | type HookTokens_Socket_Call struct { | |
| 143 | + | *mock.Call | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Socket is a helper method to define mock.On call | |
| 147 | + | func (_e *HookTokens_Expecter) Socket() *HookTokens_Socket_Call { | |
| 148 | + | return &HookTokens_Socket_Call{Call: _e.mock.On("Socket")} | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (_c *HookTokens_Socket_Call) Run(run func()) *HookTokens_Socket_Call { | |
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 153 | + | run() | |
| 154 | + | }) | |
| 155 | + | return _c | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (_c *HookTokens_Socket_Call) Return(s string) *HookTokens_Socket_Call { | |
| 159 | + | _c.Call.Return(s) | |
| 160 | + | return _c | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *HookTokens_Socket_Call) RunAndReturn(run func() string) *HookTokens_Socket_Call { | |
| 164 | + | _c.Call.Return(run) | |
| 165 | + | return _c | |
| 166 | + | } | |
A internal/ports/mocks/MaintenanceScheduler.go +94 -0
| @@ -0,0 +1,94 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMaintenanceScheduler creates a new instance of MaintenanceScheduler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMaintenanceScheduler(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MaintenanceScheduler { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &MaintenanceScheduler{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // MaintenanceScheduler is an autogenerated mock type for the MaintenanceScheduler type | |
| 38 | + | type MaintenanceScheduler struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type MaintenanceScheduler_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *MaintenanceScheduler) EXPECT() *MaintenanceScheduler_Expecter { | |
| 47 | + | return &MaintenanceScheduler_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Schedule provides a mock function for the type MaintenanceScheduler | |
| 51 | + | func (_mock *MaintenanceScheduler) Schedule(ctx context.Context, name domain.RepoName) { | |
| 52 | + | _mock.Called(ctx, name) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // MaintenanceScheduler_Schedule_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Schedule' | |
| 57 | + | type MaintenanceScheduler_Schedule_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Schedule is a helper method to define mock.On call | |
| 62 | + | // - ctx context.Context | |
| 63 | + | // - name domain.RepoName | |
| 64 | + | func (_e *MaintenanceScheduler_Expecter) Schedule(ctx any, name any) *MaintenanceScheduler_Schedule_Call { | |
| 65 | + | return &MaintenanceScheduler_Schedule_Call{Call: _e.mock.On("Schedule", ctx, name)} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (_c *MaintenanceScheduler_Schedule_Call) Run(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |
| 69 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 70 | + | var arg0 context.Context | |
| 71 | + | if args[0] != nil { | |
| 72 | + | arg0 = args[0].(context.Context) | |
| 73 | + | } | |
| 74 | + | var arg1 domain.RepoName | |
| 75 | + | if args[1] != nil { | |
| 76 | + | arg1 = args[1].(domain.RepoName) | |
| 77 | + | } | |
| 78 | + | run( | |
| 79 | + | arg0, | |
| 80 | + | arg1, | |
| 81 | + | ) | |
| 82 | + | }) | |
| 83 | + | return _c | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (_c *MaintenanceScheduler_Schedule_Call) Return() *MaintenanceScheduler_Schedule_Call { | |
| 87 | + | _c.Call.Return() | |
| 88 | + | return _c | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *MaintenanceScheduler_Schedule_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName)) *MaintenanceScheduler_Schedule_Call { | |
| 92 | + | _c.Run(run) | |
| 93 | + | return _c | |
| 94 | + | } | |
A internal/ports/mocks/MergeRequestService.go +709 -0
| @@ -0,0 +1,709 @@ | |||
| 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 | + | // NewMergeRequestService creates a new instance of MergeRequestService. 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 NewMergeRequestService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MergeRequestService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &MergeRequestService{} | |
| 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 | + | // MergeRequestService is an autogenerated mock type for the MergeRequestService type | |
| 39 | + | type MergeRequestService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type MergeRequestService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *MergeRequestService) EXPECT() *MergeRequestService_Expecter { | |
| 48 | + | return &MergeRequestService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // AddComment provides a mock function for the type MergeRequestService | |
| 52 | + | func (_mock *MergeRequestService) AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, name, number, params) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for AddComment") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.Comment | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) (domain.Comment, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, name, number, params) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) domain.Comment); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, name, number, params) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.Comment) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.AddCommentParams) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, name, number, params) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // MergeRequestService_AddComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddComment' | |
| 78 | + | type MergeRequestService_AddComment_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // AddComment is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - name domain.RepoName | |
| 86 | + | // - number int64 | |
| 87 | + | // - params ports.AddCommentParams | |
| 88 | + | func (_e *MergeRequestService_Expecter) AddComment(ctx any, p any, name any, number any, params any) *MergeRequestService_AddComment_Call { | |
| 89 | + | return &MergeRequestService_AddComment_Call{Call: _e.mock.On("AddComment", ctx, p, name, number, params)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *MergeRequestService_AddComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams)) *MergeRequestService_AddComment_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.Principal | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.Principal) | |
| 101 | + | } | |
| 102 | + | var arg2 domain.RepoName | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(domain.RepoName) | |
| 105 | + | } | |
| 106 | + | var arg3 int64 | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(int64) | |
| 109 | + | } | |
| 110 | + | var arg4 ports.AddCommentParams | |
| 111 | + | if args[4] != nil { | |
| 112 | + | arg4 = args[4].(ports.AddCommentParams) | |
| 113 | + | } | |
| 114 | + | run( | |
| 115 | + | arg0, | |
| 116 | + | arg1, | |
| 117 | + | arg2, | |
| 118 | + | arg3, | |
| 119 | + | arg4, | |
| 120 | + | ) | |
| 121 | + | }) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *MergeRequestService_AddComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_AddComment_Call { | |
| 126 | + | _c.Call.Return(comment, err) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *MergeRequestService_AddComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.AddCommentParams) (domain.Comment, error)) *MergeRequestService_AddComment_Call { | |
| 131 | + | _c.Call.Return(run) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Close provides a mock function for the type MergeRequestService | |
| 136 | + | func (_mock *MergeRequestService) Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) { | |
| 137 | + | ret := _mock.Called(ctx, p, name, number) | |
| 138 | + | ||
| 139 | + | if len(ret) == 0 { | |
| 140 | + | panic("no return value specified for Close") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | var r0 domain.MergeRequest | |
| 144 | + | var r1 error | |
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok { | |
| 146 | + | return returnFunc(ctx, p, name, number) | |
| 147 | + | } | |
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok { | |
| 149 | + | r0 = returnFunc(ctx, p, name, number) | |
| 150 | + | } else { | |
| 151 | + | r0 = ret.Get(0).(domain.MergeRequest) | |
| 152 | + | } | |
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok { | |
| 154 | + | r1 = returnFunc(ctx, p, name, number) | |
| 155 | + | } else { | |
| 156 | + | r1 = ret.Error(1) | |
| 157 | + | } | |
| 158 | + | return r0, r1 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // MergeRequestService_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 162 | + | type MergeRequestService_Close_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Close is a helper method to define mock.On call | |
| 167 | + | // - ctx context.Context | |
| 168 | + | // - p domain.Principal | |
| 169 | + | // - name domain.RepoName | |
| 170 | + | // - number int64 | |
| 171 | + | func (_e *MergeRequestService_Expecter) Close(ctx any, p any, name any, number any) *MergeRequestService_Close_Call { | |
| 172 | + | return &MergeRequestService_Close_Call{Call: _e.mock.On("Close", ctx, p, name, number)} | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (_c *MergeRequestService_Close_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Close_Call { | |
| 176 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 177 | + | var arg0 context.Context | |
| 178 | + | if args[0] != nil { | |
| 179 | + | arg0 = args[0].(context.Context) | |
| 180 | + | } | |
| 181 | + | var arg1 domain.Principal | |
| 182 | + | if args[1] != nil { | |
| 183 | + | arg1 = args[1].(domain.Principal) | |
| 184 | + | } | |
| 185 | + | var arg2 domain.RepoName | |
| 186 | + | if args[2] != nil { | |
| 187 | + | arg2 = args[2].(domain.RepoName) | |
| 188 | + | } | |
| 189 | + | var arg3 int64 | |
| 190 | + | if args[3] != nil { | |
| 191 | + | arg3 = args[3].(int64) | |
| 192 | + | } | |
| 193 | + | run( | |
| 194 | + | arg0, | |
| 195 | + | arg1, | |
| 196 | + | arg2, | |
| 197 | + | arg3, | |
| 198 | + | ) | |
| 199 | + | }) | |
| 200 | + | return _c | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (_c *MergeRequestService_Close_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_Close_Call { | |
| 204 | + | _c.Call.Return(mergeRequest, err) | |
| 205 | + | return _c | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_Close_Call { | |
| 209 | + | _c.Call.Return(run) | |
| 210 | + | return _c | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Create provides a mock function for the type MergeRequestService | |
| 214 | + | func (_mock *MergeRequestService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) { | |
| 215 | + | ret := _mock.Called(ctx, p, name, params) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for Create") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 domain.MergeRequest | |
| 222 | + | var r1 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) (domain.MergeRequest, error)); ok { | |
| 224 | + | return returnFunc(ctx, p, name, params) | |
| 225 | + | } | |
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) domain.MergeRequest); ok { | |
| 227 | + | r0 = returnFunc(ctx, p, name, params) | |
| 228 | + | } else { | |
| 229 | + | r0 = ret.Get(0).(domain.MergeRequest) | |
| 230 | + | } | |
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.CreateMergeRequestParams) error); ok { | |
| 232 | + | r1 = returnFunc(ctx, p, name, params) | |
| 233 | + | } else { | |
| 234 | + | r1 = ret.Error(1) | |
| 235 | + | } | |
| 236 | + | return r0, r1 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // MergeRequestService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 240 | + | type MergeRequestService_Create_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Create is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - p domain.Principal | |
| 247 | + | // - name domain.RepoName | |
| 248 | + | // - params ports.CreateMergeRequestParams | |
| 249 | + | func (_e *MergeRequestService_Expecter) Create(ctx any, p any, name any, params any) *MergeRequestService_Create_Call { | |
| 250 | + | return &MergeRequestService_Create_Call{Call: _e.mock.On("Create", ctx, p, name, params)} | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *MergeRequestService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams)) *MergeRequestService_Create_Call { | |
| 254 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 255 | + | var arg0 context.Context | |
| 256 | + | if args[0] != nil { | |
| 257 | + | arg0 = args[0].(context.Context) | |
| 258 | + | } | |
| 259 | + | var arg1 domain.Principal | |
| 260 | + | if args[1] != nil { | |
| 261 | + | arg1 = args[1].(domain.Principal) | |
| 262 | + | } | |
| 263 | + | var arg2 domain.RepoName | |
| 264 | + | if args[2] != nil { | |
| 265 | + | arg2 = args[2].(domain.RepoName) | |
| 266 | + | } | |
| 267 | + | var arg3 ports.CreateMergeRequestParams | |
| 268 | + | if args[3] != nil { | |
| 269 | + | arg3 = args[3].(ports.CreateMergeRequestParams) | |
| 270 | + | } | |
| 271 | + | run( | |
| 272 | + | arg0, | |
| 273 | + | arg1, | |
| 274 | + | arg2, | |
| 275 | + | arg3, | |
| 276 | + | ) | |
| 277 | + | }) | |
| 278 | + | return _c | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func (_c *MergeRequestService_Create_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_Create_Call { | |
| 282 | + | _c.Call.Return(mergeRequest, err) | |
| 283 | + | return _c | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error)) *MergeRequestService_Create_Call { | |
| 287 | + | _c.Call.Return(run) | |
| 288 | + | return _c | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Get provides a mock function for the type MergeRequestService | |
| 292 | + | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | |
| 293 | + | ret := _mock.Called(ctx, p, name, number, sinceLastRead) | |
| 294 | + | ||
| 295 | + | if len(ret) == 0 { | |
| 296 | + | panic("no return value specified for Get") | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | var r0 ports.MergeRequestDetail | |
| 300 | + | var r1 error | |
| 301 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok { | |
| 302 | + | return returnFunc(ctx, p, name, number, sinceLastRead) | |
| 303 | + | } | |
| 304 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok { | |
| 305 | + | r0 = returnFunc(ctx, p, name, number, sinceLastRead) | |
| 306 | + | } else { | |
| 307 | + | r0 = ret.Get(0).(ports.MergeRequestDetail) | |
| 308 | + | } | |
| 309 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok { | |
| 310 | + | r1 = returnFunc(ctx, p, name, number, sinceLastRead) | |
| 311 | + | } else { | |
| 312 | + | r1 = ret.Error(1) | |
| 313 | + | } | |
| 314 | + | return r0, r1 | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // MergeRequestService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 318 | + | type MergeRequestService_Get_Call struct { | |
| 319 | + | *mock.Call | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Get is a helper method to define mock.On call | |
| 323 | + | // - ctx context.Context | |
| 324 | + | // - p domain.Principal | |
| 325 | + | // - name domain.RepoName | |
| 326 | + | // - number int64 | |
| 327 | + | // - sinceLastRead bool | |
| 328 | + | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call { | |
| 329 | + | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, sinceLastRead)} | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool)) *MergeRequestService_Get_Call { | |
| 333 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 334 | + | var arg0 context.Context | |
| 335 | + | if args[0] != nil { | |
| 336 | + | arg0 = args[0].(context.Context) | |
| 337 | + | } | |
| 338 | + | var arg1 domain.Principal | |
| 339 | + | if args[1] != nil { | |
| 340 | + | arg1 = args[1].(domain.Principal) | |
| 341 | + | } | |
| 342 | + | var arg2 domain.RepoName | |
| 343 | + | if args[2] != nil { | |
| 344 | + | arg2 = args[2].(domain.RepoName) | |
| 345 | + | } | |
| 346 | + | var arg3 int64 | |
| 347 | + | if args[3] != nil { | |
| 348 | + | arg3 = args[3].(int64) | |
| 349 | + | } | |
| 350 | + | var arg4 bool | |
| 351 | + | if args[4] != nil { | |
| 352 | + | arg4 = args[4].(bool) | |
| 353 | + | } | |
| 354 | + | run( | |
| 355 | + | arg0, | |
| 356 | + | arg1, | |
| 357 | + | arg2, | |
| 358 | + | arg3, | |
| 359 | + | arg4, | |
| 360 | + | ) | |
| 361 | + | }) | |
| 362 | + | return _c | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeRequestDetail, err error) *MergeRequestService_Get_Call { | |
| 366 | + | _c.Call.Return(mergeRequestDetail, err) | |
| 367 | + | return _c | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { | |
| 371 | + | _c.Call.Return(run) | |
| 372 | + | return _c | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // List provides a mock function for the type MergeRequestService | |
| 376 | + | func (_mock *MergeRequestService) List(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error) { | |
| 377 | + | ret := _mock.Called(ctx, p, name, params) | |
| 378 | + | ||
| 379 | + | if len(ret) == 0 { | |
| 380 | + | panic("no return value specified for List") | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | var r0 ports.MergeRequestPage | |
| 384 | + | var r1 error | |
| 385 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) (ports.MergeRequestPage, error)); ok { | |
| 386 | + | return returnFunc(ctx, p, name, params) | |
| 387 | + | } | |
| 388 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) ports.MergeRequestPage); ok { | |
| 389 | + | r0 = returnFunc(ctx, p, name, params) | |
| 390 | + | } else { | |
| 391 | + | r0 = ret.Get(0).(ports.MergeRequestPage) | |
| 392 | + | } | |
| 393 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListMergeRequestsParams) error); ok { | |
| 394 | + | r1 = returnFunc(ctx, p, name, params) | |
| 395 | + | } else { | |
| 396 | + | r1 = ret.Error(1) | |
| 397 | + | } | |
| 398 | + | return r0, r1 | |
| 399 | + | } | |
| 400 | + | ||
| 401 | + | // MergeRequestService_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 402 | + | type MergeRequestService_List_Call struct { | |
| 403 | + | *mock.Call | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // List is a helper method to define mock.On call | |
| 407 | + | // - ctx context.Context | |
| 408 | + | // - p domain.Principal | |
| 409 | + | // - name domain.RepoName | |
| 410 | + | // - params ports.ListMergeRequestsParams | |
| 411 | + | func (_e *MergeRequestService_Expecter) List(ctx any, p any, name any, params any) *MergeRequestService_List_Call { | |
| 412 | + | return &MergeRequestService_List_Call{Call: _e.mock.On("List", ctx, p, name, params)} | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func (_c *MergeRequestService_List_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams)) *MergeRequestService_List_Call { | |
| 416 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 417 | + | var arg0 context.Context | |
| 418 | + | if args[0] != nil { | |
| 419 | + | arg0 = args[0].(context.Context) | |
| 420 | + | } | |
| 421 | + | var arg1 domain.Principal | |
| 422 | + | if args[1] != nil { | |
| 423 | + | arg1 = args[1].(domain.Principal) | |
| 424 | + | } | |
| 425 | + | var arg2 domain.RepoName | |
| 426 | + | if args[2] != nil { | |
| 427 | + | arg2 = args[2].(domain.RepoName) | |
| 428 | + | } | |
| 429 | + | var arg3 ports.ListMergeRequestsParams | |
| 430 | + | if args[3] != nil { | |
| 431 | + | arg3 = args[3].(ports.ListMergeRequestsParams) | |
| 432 | + | } | |
| 433 | + | run( | |
| 434 | + | arg0, | |
| 435 | + | arg1, | |
| 436 | + | arg2, | |
| 437 | + | arg3, | |
| 438 | + | ) | |
| 439 | + | }) | |
| 440 | + | return _c | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func (_c *MergeRequestService_List_Call) Return(mergeRequestPage ports.MergeRequestPage, err error) *MergeRequestService_List_Call { | |
| 444 | + | _c.Call.Return(mergeRequestPage, err) | |
| 445 | + | return _c | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func (_c *MergeRequestService_List_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListMergeRequestsParams) (ports.MergeRequestPage, error)) *MergeRequestService_List_Call { | |
| 449 | + | _c.Call.Return(run) | |
| 450 | + | return _c | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | // MarkRead provides a mock function for the type MergeRequestService | |
| 454 | + | func (_mock *MergeRequestService) MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error) { | |
| 455 | + | ret := _mock.Called(ctx, p, name, number, head) | |
| 456 | + | ||
| 457 | + | if len(ret) == 0 { | |
| 458 | + | panic("no return value specified for MarkRead") | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | var r0 domain.ObjectID | |
| 462 | + | var r1 error | |
| 463 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) (domain.ObjectID, error)); ok { | |
| 464 | + | return returnFunc(ctx, p, name, number, head) | |
| 465 | + | } | |
| 466 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) domain.ObjectID); ok { | |
| 467 | + | r0 = returnFunc(ctx, p, name, number, head) | |
| 468 | + | } else { | |
| 469 | + | r0 = ret.Get(0).(domain.ObjectID) | |
| 470 | + | } | |
| 471 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, domain.ObjectID) error); ok { | |
| 472 | + | r1 = returnFunc(ctx, p, name, number, head) | |
| 473 | + | } else { | |
| 474 | + | r1 = ret.Error(1) | |
| 475 | + | } | |
| 476 | + | return r0, r1 | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // MergeRequestService_MarkRead_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkRead' | |
| 480 | + | type MergeRequestService_MarkRead_Call struct { | |
| 481 | + | *mock.Call | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // MarkRead is a helper method to define mock.On call | |
| 485 | + | // - ctx context.Context | |
| 486 | + | // - p domain.Principal | |
| 487 | + | // - name domain.RepoName | |
| 488 | + | // - number int64 | |
| 489 | + | // - head domain.ObjectID | |
| 490 | + | func (_e *MergeRequestService_Expecter) MarkRead(ctx any, p any, name any, number any, head any) *MergeRequestService_MarkRead_Call { | |
| 491 | + | return &MergeRequestService_MarkRead_Call{Call: _e.mock.On("MarkRead", ctx, p, name, number, head)} | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | func (_c *MergeRequestService_MarkRead_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID)) *MergeRequestService_MarkRead_Call { | |
| 495 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 496 | + | var arg0 context.Context | |
| 497 | + | if args[0] != nil { | |
| 498 | + | arg0 = args[0].(context.Context) | |
| 499 | + | } | |
| 500 | + | var arg1 domain.Principal | |
| 501 | + | if args[1] != nil { | |
| 502 | + | arg1 = args[1].(domain.Principal) | |
| 503 | + | } | |
| 504 | + | var arg2 domain.RepoName | |
| 505 | + | if args[2] != nil { | |
| 506 | + | arg2 = args[2].(domain.RepoName) | |
| 507 | + | } | |
| 508 | + | var arg3 int64 | |
| 509 | + | if args[3] != nil { | |
| 510 | + | arg3 = args[3].(int64) | |
| 511 | + | } | |
| 512 | + | var arg4 domain.ObjectID | |
| 513 | + | if args[4] != nil { | |
| 514 | + | arg4 = args[4].(domain.ObjectID) | |
| 515 | + | } | |
| 516 | + | run( | |
| 517 | + | arg0, | |
| 518 | + | arg1, | |
| 519 | + | arg2, | |
| 520 | + | arg3, | |
| 521 | + | arg4, | |
| 522 | + | ) | |
| 523 | + | }) | |
| 524 | + | return _c | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func (_c *MergeRequestService_MarkRead_Call) Return(objectID domain.ObjectID, err error) *MergeRequestService_MarkRead_Call { | |
| 528 | + | _c.Call.Return(objectID, err) | |
| 529 | + | return _c | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func (_c *MergeRequestService_MarkRead_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)) *MergeRequestService_MarkRead_Call { | |
| 533 | + | _c.Call.Return(run) | |
| 534 | + | return _c | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | // Merge provides a mock function for the type MergeRequestService | |
| 538 | + | func (_mock *MergeRequestService) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) { | |
| 539 | + | ret := _mock.Called(ctx, p, name, number, params) | |
| 540 | + | ||
| 541 | + | if len(ret) == 0 { | |
| 542 | + | panic("no return value specified for Merge") | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | var r0 ports.MergeResult | |
| 546 | + | var r1 error | |
| 547 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) (ports.MergeResult, error)); ok { | |
| 548 | + | return returnFunc(ctx, p, name, number, params) | |
| 549 | + | } | |
| 550 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) ports.MergeResult); ok { | |
| 551 | + | r0 = returnFunc(ctx, p, name, number, params) | |
| 552 | + | } else { | |
| 553 | + | r0 = ret.Get(0).(ports.MergeResult) | |
| 554 | + | } | |
| 555 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.MergeParams) error); ok { | |
| 556 | + | r1 = returnFunc(ctx, p, name, number, params) | |
| 557 | + | } else { | |
| 558 | + | r1 = ret.Error(1) | |
| 559 | + | } | |
| 560 | + | return r0, r1 | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | // MergeRequestService_Merge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Merge' | |
| 564 | + | type MergeRequestService_Merge_Call struct { | |
| 565 | + | *mock.Call | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | // Merge is a helper method to define mock.On call | |
| 569 | + | // - ctx context.Context | |
| 570 | + | // - p domain.Principal | |
| 571 | + | // - name domain.RepoName | |
| 572 | + | // - number int64 | |
| 573 | + | // - params ports.MergeParams | |
| 574 | + | func (_e *MergeRequestService_Expecter) Merge(ctx any, p any, name any, number any, params any) *MergeRequestService_Merge_Call { | |
| 575 | + | return &MergeRequestService_Merge_Call{Call: _e.mock.On("Merge", ctx, p, name, number, params)} | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | func (_c *MergeRequestService_Merge_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams)) *MergeRequestService_Merge_Call { | |
| 579 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 580 | + | var arg0 context.Context | |
| 581 | + | if args[0] != nil { | |
| 582 | + | arg0 = args[0].(context.Context) | |
| 583 | + | } | |
| 584 | + | var arg1 domain.Principal | |
| 585 | + | if args[1] != nil { | |
| 586 | + | arg1 = args[1].(domain.Principal) | |
| 587 | + | } | |
| 588 | + | var arg2 domain.RepoName | |
| 589 | + | if args[2] != nil { | |
| 590 | + | arg2 = args[2].(domain.RepoName) | |
| 591 | + | } | |
| 592 | + | var arg3 int64 | |
| 593 | + | if args[3] != nil { | |
| 594 | + | arg3 = args[3].(int64) | |
| 595 | + | } | |
| 596 | + | var arg4 ports.MergeParams | |
| 597 | + | if args[4] != nil { | |
| 598 | + | arg4 = args[4].(ports.MergeParams) | |
| 599 | + | } | |
| 600 | + | run( | |
| 601 | + | arg0, | |
| 602 | + | arg1, | |
| 603 | + | arg2, | |
| 604 | + | arg3, | |
| 605 | + | arg4, | |
| 606 | + | ) | |
| 607 | + | }) | |
| 608 | + | return _c | |
| 609 | + | } | |
| 610 | + | ||
| 611 | + | func (_c *MergeRequestService_Merge_Call) Return(mergeResult ports.MergeResult, err error) *MergeRequestService_Merge_Call { | |
| 612 | + | _c.Call.Return(mergeResult, err) | |
| 613 | + | return _c | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error)) *MergeRequestService_Merge_Call { | |
| 617 | + | _c.Call.Return(run) | |
| 618 | + | return _c | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | // ResolveComment provides a mock function for the type MergeRequestService | |
| 622 | + | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |
| 623 | + | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | |
| 624 | + | ||
| 625 | + | if len(ret) == 0 { | |
| 626 | + | panic("no return value specified for ResolveComment") | |
| 627 | + | } | |
| 628 | + | ||
| 629 | + | var r0 domain.Comment | |
| 630 | + | var r1 error | |
| 631 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) (domain.Comment, error)); ok { | |
| 632 | + | return returnFunc(ctx, p, name, number, commentID, resolved) | |
| 633 | + | } | |
| 634 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) domain.Comment); ok { | |
| 635 | + | r0 = returnFunc(ctx, p, name, number, commentID, resolved) | |
| 636 | + | } else { | |
| 637 | + | r0 = ret.Get(0).(domain.Comment) | |
| 638 | + | } | |
| 639 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int64, bool) error); ok { | |
| 640 | + | r1 = returnFunc(ctx, p, name, number, commentID, resolved) | |
| 641 | + | } else { | |
| 642 | + | r1 = ret.Error(1) | |
| 643 | + | } | |
| 644 | + | return r0, r1 | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | // MergeRequestService_ResolveComment_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveComment' | |
| 648 | + | type MergeRequestService_ResolveComment_Call struct { | |
| 649 | + | *mock.Call | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | // ResolveComment is a helper method to define mock.On call | |
| 653 | + | // - ctx context.Context | |
| 654 | + | // - p domain.Principal | |
| 655 | + | // - name domain.RepoName | |
| 656 | + | // - number int64 | |
| 657 | + | // - commentID int64 | |
| 658 | + | // - resolved bool | |
| 659 | + | func (_e *MergeRequestService_Expecter) ResolveComment(ctx any, p any, name any, number any, commentID any, resolved any) *MergeRequestService_ResolveComment_Call { | |
| 660 | + | return &MergeRequestService_ResolveComment_Call{Call: _e.mock.On("ResolveComment", ctx, p, name, number, commentID, resolved)} | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | func (_c *MergeRequestService_ResolveComment_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool)) *MergeRequestService_ResolveComment_Call { | |
| 664 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 665 | + | var arg0 context.Context | |
| 666 | + | if args[0] != nil { | |
| 667 | + | arg0 = args[0].(context.Context) | |
| 668 | + | } | |
| 669 | + | var arg1 domain.Principal | |
| 670 | + | if args[1] != nil { | |
| 671 | + | arg1 = args[1].(domain.Principal) | |
| 672 | + | } | |
| 673 | + | var arg2 domain.RepoName | |
| 674 | + | if args[2] != nil { | |
| 675 | + | arg2 = args[2].(domain.RepoName) | |
| 676 | + | } | |
| 677 | + | var arg3 int64 | |
| 678 | + | if args[3] != nil { | |
| 679 | + | arg3 = args[3].(int64) | |
| 680 | + | } | |
| 681 | + | var arg4 int64 | |
| 682 | + | if args[4] != nil { | |
| 683 | + | arg4 = args[4].(int64) | |
| 684 | + | } | |
| 685 | + | var arg5 bool | |
| 686 | + | if args[5] != nil { | |
| 687 | + | arg5 = args[5].(bool) | |
| 688 | + | } | |
| 689 | + | run( | |
| 690 | + | arg0, | |
| 691 | + | arg1, | |
| 692 | + | arg2, | |
| 693 | + | arg3, | |
| 694 | + | arg4, | |
| 695 | + | arg5, | |
| 696 | + | ) | |
| 697 | + | }) | |
| 698 | + | return _c | |
| 699 | + | } | |
| 700 | + | ||
| 701 | + | func (_c *MergeRequestService_ResolveComment_Call) Return(comment domain.Comment, err error) *MergeRequestService_ResolveComment_Call { | |
| 702 | + | _c.Call.Return(comment, err) | |
| 703 | + | return _c | |
| 704 | + | } | |
| 705 | + | ||
| 706 | + | func (_c *MergeRequestService_ResolveComment_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error)) *MergeRequestService_ResolveComment_Call { | |
| 707 | + | _c.Call.Return(run) | |
| 708 | + | return _c | |
| 709 | + | } | |
A internal/ports/mocks/PushHookService.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPushHookService creates a new instance of PushHookService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPushHookService(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *PushHookService { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &PushHookService{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // PushHookService is an autogenerated mock type for the PushHookService type | |
| 38 | + | type PushHookService struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type PushHookService_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *PushHookService) EXPECT() *PushHookService_Expecter { | |
| 47 | + | return &PushHookService_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Check provides a mock function for the type PushHookService | |
| 51 | + | func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 52 | + | ret := _mock.Called(ctx, p, name, updates) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Check") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p, name, updates) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PushHookService_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check' | |
| 68 | + | type PushHookService_Check_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Check is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | // - name domain.RepoName | |
| 76 | + | // - updates []domain.RefUpdate | |
| 77 | + | func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any) *PushHookService_Check_Call { | |
| 78 | + | return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Check_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 domain.Principal | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(domain.Principal) | |
| 90 | + | } | |
| 91 | + | var arg2 domain.RepoName | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(domain.RepoName) | |
| 94 | + | } | |
| 95 | + | var arg3 []domain.RefUpdate | |
| 96 | + | if args[3] != nil { | |
| 97 | + | arg3 = args[3].([]domain.RefUpdate) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | arg2, | |
| 103 | + | arg3, | |
| 104 | + | ) | |
| 105 | + | }) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_Call { | |
| 110 | + | _c.Call.Return(err) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Check_Call { | |
| 115 | + | _c.Call.Return(run) | |
| 116 | + | return _c | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Record provides a mock function for the type PushHookService | |
| 120 | + | func (_mock *PushHookService) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error { | |
| 121 | + | ret := _mock.Called(ctx, p, name, updates) | |
| 122 | + | ||
| 123 | + | if len(ret) == 0 { | |
| 124 | + | panic("no return value specified for Record") | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | var r0 error | |
| 128 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok { | |
| 129 | + | r0 = returnFunc(ctx, p, name, updates) | |
| 130 | + | } else { | |
| 131 | + | r0 = ret.Error(0) | |
| 132 | + | } | |
| 133 | + | return r0 | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // PushHookService_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record' | |
| 137 | + | type PushHookService_Record_Call struct { | |
| 138 | + | *mock.Call | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Record is a helper method to define mock.On call | |
| 142 | + | // - ctx context.Context | |
| 143 | + | // - p domain.Principal | |
| 144 | + | // - name domain.RepoName | |
| 145 | + | // - updates []domain.RefUpdate | |
| 146 | + | func (_e *PushHookService_Expecter) Record(ctx any, p any, name any, updates any) *PushHookService_Record_Call { | |
| 147 | + | return &PushHookService_Record_Call{Call: _e.mock.On("Record", ctx, p, name, updates)} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (_c *PushHookService_Record_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Record_Call { | |
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 152 | + | var arg0 context.Context | |
| 153 | + | if args[0] != nil { | |
| 154 | + | arg0 = args[0].(context.Context) | |
| 155 | + | } | |
| 156 | + | var arg1 domain.Principal | |
| 157 | + | if args[1] != nil { | |
| 158 | + | arg1 = args[1].(domain.Principal) | |
| 159 | + | } | |
| 160 | + | var arg2 domain.RepoName | |
| 161 | + | if args[2] != nil { | |
| 162 | + | arg2 = args[2].(domain.RepoName) | |
| 163 | + | } | |
| 164 | + | var arg3 []domain.RefUpdate | |
| 165 | + | if args[3] != nil { | |
| 166 | + | arg3 = args[3].([]domain.RefUpdate) | |
| 167 | + | } | |
| 168 | + | run( | |
| 169 | + | arg0, | |
| 170 | + | arg1, | |
| 171 | + | arg2, | |
| 172 | + | arg3, | |
| 173 | + | ) | |
| 174 | + | }) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (_c *PushHookService_Record_Call) Return(err error) *PushHookService_Record_Call { | |
| 179 | + | _c.Call.Return(err) | |
| 180 | + | return _c | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func (_c *PushHookService_Record_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Record_Call { | |
| 184 | + | _c.Call.Return(run) | |
| 185 | + | return _c | |
| 186 | + | } | |
A internal/ports/mocks/PushService.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewPushService creates a new instance of PushService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewPushService(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *PushService { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &PushService{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // PushService is an autogenerated mock type for the PushService type | |
| 38 | + | type PushService struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type PushService_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *PushService) EXPECT() *PushService_Expecter { | |
| 47 | + | return &PushService_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Open provides a mock function for the type PushService | |
| 51 | + | func (_mock *PushService) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error { | |
| 52 | + | ret := _mock.Called(ctx, p, name, create) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Open") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p, name, create) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // PushService_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open' | |
| 68 | + | type PushService_Open_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Open is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | // - name domain.RepoName | |
| 76 | + | // - create bool | |
| 77 | + | func (_e *PushService_Expecter) Open(ctx any, p any, name any, create any) *PushService_Open_Call { | |
| 78 | + | return &PushService_Open_Call{Call: _e.mock.On("Open", ctx, p, name, create)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *PushService_Open_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool)) *PushService_Open_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 domain.Principal | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(domain.Principal) | |
| 90 | + | } | |
| 91 | + | var arg2 domain.RepoName | |
| 92 | + | if args[2] != nil { | |
| 93 | + | arg2 = args[2].(domain.RepoName) | |
| 94 | + | } | |
| 95 | + | var arg3 bool | |
| 96 | + | if args[3] != nil { | |
| 97 | + | arg3 = args[3].(bool) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | arg2, | |
| 103 | + | arg3, | |
| 104 | + | ) | |
| 105 | + | }) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (_c *PushService_Open_Call) Return(err error) *PushService_Open_Call { | |
| 110 | + | _c.Call.Return(err) | |
| 111 | + | return _c | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (_c *PushService_Open_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error) *PushService_Open_Call { | |
| 115 | + | _c.Call.Return(run) | |
| 116 | + | return _c | |
| 117 | + | } | |
A internal/ports/mocks/RepoLookup.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewRepoLookup creates a new instance of RepoLookup. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewRepoLookup(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *RepoLookup { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &RepoLookup{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // RepoLookup is an autogenerated mock type for the RepoLookup type | |
| 38 | + | type RepoLookup struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type RepoLookup_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *RepoLookup) EXPECT() *RepoLookup_Expecter { | |
| 47 | + | return &RepoLookup_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Get provides a mock function for the type RepoLookup | |
| 51 | + | func (_mock *RepoLookup) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 52 | + | ret := _mock.Called(ctx, p, name) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Get") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 domain.Repo | |
| 59 | + | var r1 error | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |
| 61 | + | return returnFunc(ctx, p, name) | |
| 62 | + | } | |
| 63 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |
| 64 | + | r0 = returnFunc(ctx, p, name) | |
| 65 | + | } else { | |
| 66 | + | r0 = ret.Get(0).(domain.Repo) | |
| 67 | + | } | |
| 68 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 69 | + | r1 = returnFunc(ctx, p, name) | |
| 70 | + | } else { | |
| 71 | + | r1 = ret.Error(1) | |
| 72 | + | } | |
| 73 | + | return r0, r1 | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // RepoLookup_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 77 | + | type RepoLookup_Get_Call struct { | |
| 78 | + | *mock.Call | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Get is a helper method to define mock.On call | |
| 82 | + | // - ctx context.Context | |
| 83 | + | // - p domain.Principal | |
| 84 | + | // - name domain.RepoName | |
| 85 | + | func (_e *RepoLookup_Expecter) Get(ctx any, p any, name any) *RepoLookup_Get_Call { | |
| 86 | + | return &RepoLookup_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (_c *RepoLookup_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoLookup_Get_Call { | |
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 91 | + | var arg0 context.Context | |
| 92 | + | if args[0] != nil { | |
| 93 | + | arg0 = args[0].(context.Context) | |
| 94 | + | } | |
| 95 | + | var arg1 domain.Principal | |
| 96 | + | if args[1] != nil { | |
| 97 | + | arg1 = args[1].(domain.Principal) | |
| 98 | + | } | |
| 99 | + | var arg2 domain.RepoName | |
| 100 | + | if args[2] != nil { | |
| 101 | + | arg2 = args[2].(domain.RepoName) | |
| 102 | + | } | |
| 103 | + | run( | |
| 104 | + | arg0, | |
| 105 | + | arg1, | |
| 106 | + | arg2, | |
| 107 | + | ) | |
| 108 | + | }) | |
| 109 | + | return _c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (_c *RepoLookup_Get_Call) Return(repo domain.Repo, err error) *RepoLookup_Get_Call { | |
| 113 | + | _c.Call.Return(repo, err) | |
| 114 | + | return _c | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (_c *RepoLookup_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *RepoLookup_Get_Call { | |
| 118 | + | _c.Call.Return(run) | |
| 119 | + | return _c | |
| 120 | + | } | |
A internal/ports/mocks/RepoService.go +478 -0
| @@ -0,0 +1,478 @@ | |||
| 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 | + | // NewRepoService creates a new instance of RepoService. 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 NewRepoService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *RepoService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &RepoService{} | |
| 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 | + | // RepoService is an autogenerated mock type for the RepoService type | |
| 39 | + | type RepoService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type RepoService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *RepoService) EXPECT() *RepoService_Expecter { | |
| 48 | + | return &RepoService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Create provides a mock function for the type RepoService | |
| 52 | + | func (_mock *RepoService) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, params) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Create") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 domain.Repo | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateRepoParams) (domain.Repo, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, params) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateRepoParams) domain.Repo); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, params) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(domain.Repo) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.CreateRepoParams) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, params) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // RepoService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 78 | + | type RepoService_Create_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Create is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - params ports.CreateRepoParams | |
| 86 | + | func (_e *RepoService_Expecter) Create(ctx any, p any, params any) *RepoService_Create_Call { | |
| 87 | + | return &RepoService_Create_Call{Call: _e.mock.On("Create", ctx, p, params)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *RepoService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.CreateRepoParams)) *RepoService_Create_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.Principal | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.Principal) | |
| 99 | + | } | |
| 100 | + | var arg2 ports.CreateRepoParams | |
| 101 | + | if args[2] != nil { | |
| 102 | + | arg2 = args[2].(ports.CreateRepoParams) | |
| 103 | + | } | |
| 104 | + | run( | |
| 105 | + | arg0, | |
| 106 | + | arg1, | |
| 107 | + | arg2, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *RepoService_Create_Call) Return(repo domain.Repo, err error) *RepoService_Create_Call { | |
| 114 | + | _c.Call.Return(repo, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *RepoService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error)) *RepoService_Create_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Delete provides a mock function for the type RepoService | |
| 124 | + | func (_mock *RepoService) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error { | |
| 125 | + | ret := _mock.Called(ctx, p, name) | |
| 126 | + | ||
| 127 | + | if len(ret) == 0 { | |
| 128 | + | panic("no return value specified for Delete") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | var r0 error | |
| 132 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 133 | + | r0 = returnFunc(ctx, p, name) | |
| 134 | + | } else { | |
| 135 | + | r0 = ret.Error(0) | |
| 136 | + | } | |
| 137 | + | return r0 | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // RepoService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 141 | + | type RepoService_Delete_Call struct { | |
| 142 | + | *mock.Call | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Delete is a helper method to define mock.On call | |
| 146 | + | // - ctx context.Context | |
| 147 | + | // - p domain.Principal | |
| 148 | + | // - name domain.RepoName | |
| 149 | + | func (_e *RepoService_Expecter) Delete(ctx any, p any, name any) *RepoService_Delete_Call { | |
| 150 | + | return &RepoService_Delete_Call{Call: _e.mock.On("Delete", ctx, p, name)} | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (_c *RepoService_Delete_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoService_Delete_Call { | |
| 154 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 155 | + | var arg0 context.Context | |
| 156 | + | if args[0] != nil { | |
| 157 | + | arg0 = args[0].(context.Context) | |
| 158 | + | } | |
| 159 | + | var arg1 domain.Principal | |
| 160 | + | if args[1] != nil { | |
| 161 | + | arg1 = args[1].(domain.Principal) | |
| 162 | + | } | |
| 163 | + | var arg2 domain.RepoName | |
| 164 | + | if args[2] != nil { | |
| 165 | + | arg2 = args[2].(domain.RepoName) | |
| 166 | + | } | |
| 167 | + | run( | |
| 168 | + | arg0, | |
| 169 | + | arg1, | |
| 170 | + | arg2, | |
| 171 | + | ) | |
| 172 | + | }) | |
| 173 | + | return _c | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (_c *RepoService_Delete_Call) Return(err error) *RepoService_Delete_Call { | |
| 177 | + | _c.Call.Return(err) | |
| 178 | + | return _c | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (_c *RepoService_Delete_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) error) *RepoService_Delete_Call { | |
| 182 | + | _c.Call.Return(run) | |
| 183 | + | return _c | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Get provides a mock function for the type RepoService | |
| 187 | + | func (_mock *RepoService) Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) { | |
| 188 | + | ret := _mock.Called(ctx, p, name) | |
| 189 | + | ||
| 190 | + | if len(ret) == 0 { | |
| 191 | + | panic("no return value specified for Get") | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | var r0 domain.Repo | |
| 195 | + | var r1 error | |
| 196 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.Repo, error)); ok { | |
| 197 | + | return returnFunc(ctx, p, name) | |
| 198 | + | } | |
| 199 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.Repo); ok { | |
| 200 | + | r0 = returnFunc(ctx, p, name) | |
| 201 | + | } else { | |
| 202 | + | r0 = ret.Get(0).(domain.Repo) | |
| 203 | + | } | |
| 204 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok { | |
| 205 | + | r1 = returnFunc(ctx, p, name) | |
| 206 | + | } else { | |
| 207 | + | r1 = ret.Error(1) | |
| 208 | + | } | |
| 209 | + | return r0, r1 | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // RepoService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 213 | + | type RepoService_Get_Call struct { | |
| 214 | + | *mock.Call | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Get is a helper method to define mock.On call | |
| 218 | + | // - ctx context.Context | |
| 219 | + | // - p domain.Principal | |
| 220 | + | // - name domain.RepoName | |
| 221 | + | func (_e *RepoService_Expecter) Get(ctx any, p any, name any) *RepoService_Get_Call { | |
| 222 | + | return &RepoService_Get_Call{Call: _e.mock.On("Get", ctx, p, name)} | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func (_c *RepoService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *RepoService_Get_Call { | |
| 226 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 227 | + | var arg0 context.Context | |
| 228 | + | if args[0] != nil { | |
| 229 | + | arg0 = args[0].(context.Context) | |
| 230 | + | } | |
| 231 | + | var arg1 domain.Principal | |
| 232 | + | if args[1] != nil { | |
| 233 | + | arg1 = args[1].(domain.Principal) | |
| 234 | + | } | |
| 235 | + | var arg2 domain.RepoName | |
| 236 | + | if args[2] != nil { | |
| 237 | + | arg2 = args[2].(domain.RepoName) | |
| 238 | + | } | |
| 239 | + | run( | |
| 240 | + | arg0, | |
| 241 | + | arg1, | |
| 242 | + | arg2, | |
| 243 | + | ) | |
| 244 | + | }) | |
| 245 | + | return _c | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (_c *RepoService_Get_Call) Return(repo domain.Repo, err error) *RepoService_Get_Call { | |
| 249 | + | _c.Call.Return(repo, err) | |
| 250 | + | return _c | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *RepoService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)) *RepoService_Get_Call { | |
| 254 | + | _c.Call.Return(run) | |
| 255 | + | return _c | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // ListPage provides a mock function for the type RepoService | |
| 259 | + | func (_mock *RepoService) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error) { | |
| 260 | + | ret := _mock.Called(ctx, p, size, token) | |
| 261 | + | ||
| 262 | + | if len(ret) == 0 { | |
| 263 | + | panic("no return value specified for ListPage") | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | var r0 ports.RepoPage | |
| 267 | + | var r1 error | |
| 268 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.RepoPage, error)); ok { | |
| 269 | + | return returnFunc(ctx, p, size, token) | |
| 270 | + | } | |
| 271 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.RepoPage); ok { | |
| 272 | + | r0 = returnFunc(ctx, p, size, token) | |
| 273 | + | } else { | |
| 274 | + | r0 = ret.Get(0).(ports.RepoPage) | |
| 275 | + | } | |
| 276 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok { | |
| 277 | + | r1 = returnFunc(ctx, p, size, token) | |
| 278 | + | } else { | |
| 279 | + | r1 = ret.Error(1) | |
| 280 | + | } | |
| 281 | + | return r0, r1 | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // RepoService_ListPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPage' | |
| 285 | + | type RepoService_ListPage_Call struct { | |
| 286 | + | *mock.Call | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // ListPage is a helper method to define mock.On call | |
| 290 | + | // - ctx context.Context | |
| 291 | + | // - p domain.Principal | |
| 292 | + | // - size int | |
| 293 | + | // - token string | |
| 294 | + | func (_e *RepoService_Expecter) ListPage(ctx any, p any, size any, token any) *RepoService_ListPage_Call { | |
| 295 | + | return &RepoService_ListPage_Call{Call: _e.mock.On("ListPage", ctx, p, size, token)} | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (_c *RepoService_ListPage_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *RepoService_ListPage_Call { | |
| 299 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 300 | + | var arg0 context.Context | |
| 301 | + | if args[0] != nil { | |
| 302 | + | arg0 = args[0].(context.Context) | |
| 303 | + | } | |
| 304 | + | var arg1 domain.Principal | |
| 305 | + | if args[1] != nil { | |
| 306 | + | arg1 = args[1].(domain.Principal) | |
| 307 | + | } | |
| 308 | + | var arg2 int | |
| 309 | + | if args[2] != nil { | |
| 310 | + | arg2 = args[2].(int) | |
| 311 | + | } | |
| 312 | + | var arg3 string | |
| 313 | + | if args[3] != nil { | |
| 314 | + | arg3 = args[3].(string) | |
| 315 | + | } | |
| 316 | + | run( | |
| 317 | + | arg0, | |
| 318 | + | arg1, | |
| 319 | + | arg2, | |
| 320 | + | arg3, | |
| 321 | + | ) | |
| 322 | + | }) | |
| 323 | + | return _c | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (_c *RepoService_ListPage_Call) Return(repoPage ports.RepoPage, err error) *RepoService_ListPage_Call { | |
| 327 | + | _c.Call.Return(repoPage, err) | |
| 328 | + | return _c | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (_c *RepoService_ListPage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.RepoPage, error)) *RepoService_ListPage_Call { | |
| 332 | + | _c.Call.Return(run) | |
| 333 | + | return _c | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // Update provides a mock function for the type RepoService | |
| 337 | + | func (_mock *RepoService) Update(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error) { | |
| 338 | + | ret := _mock.Called(ctx, p, name, params) | |
| 339 | + | ||
| 340 | + | if len(ret) == 0 { | |
| 341 | + | panic("no return value specified for Update") | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | var r0 domain.Repo | |
| 345 | + | var r1 error | |
| 346 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) (domain.Repo, error)); ok { | |
| 347 | + | return returnFunc(ctx, p, name, params) | |
| 348 | + | } | |
| 349 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) domain.Repo); ok { | |
| 350 | + | r0 = returnFunc(ctx, p, name, params) | |
| 351 | + | } else { | |
| 352 | + | r0 = ret.Get(0).(domain.Repo) | |
| 353 | + | } | |
| 354 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.UpdateRepoParams) error); ok { | |
| 355 | + | r1 = returnFunc(ctx, p, name, params) | |
| 356 | + | } else { | |
| 357 | + | r1 = ret.Error(1) | |
| 358 | + | } | |
| 359 | + | return r0, r1 | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // RepoService_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' | |
| 363 | + | type RepoService_Update_Call struct { | |
| 364 | + | *mock.Call | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // Update is a helper method to define mock.On call | |
| 368 | + | // - ctx context.Context | |
| 369 | + | // - p domain.Principal | |
| 370 | + | // - name domain.RepoName | |
| 371 | + | // - params ports.UpdateRepoParams | |
| 372 | + | func (_e *RepoService_Expecter) Update(ctx any, p any, name any, params any) *RepoService_Update_Call { | |
| 373 | + | return &RepoService_Update_Call{Call: _e.mock.On("Update", ctx, p, name, params)} | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (_c *RepoService_Update_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams)) *RepoService_Update_Call { | |
| 377 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 378 | + | var arg0 context.Context | |
| 379 | + | if args[0] != nil { | |
| 380 | + | arg0 = args[0].(context.Context) | |
| 381 | + | } | |
| 382 | + | var arg1 domain.Principal | |
| 383 | + | if args[1] != nil { | |
| 384 | + | arg1 = args[1].(domain.Principal) | |
| 385 | + | } | |
| 386 | + | var arg2 domain.RepoName | |
| 387 | + | if args[2] != nil { | |
| 388 | + | arg2 = args[2].(domain.RepoName) | |
| 389 | + | } | |
| 390 | + | var arg3 ports.UpdateRepoParams | |
| 391 | + | if args[3] != nil { | |
| 392 | + | arg3 = args[3].(ports.UpdateRepoParams) | |
| 393 | + | } | |
| 394 | + | run( | |
| 395 | + | arg0, | |
| 396 | + | arg1, | |
| 397 | + | arg2, | |
| 398 | + | arg3, | |
| 399 | + | ) | |
| 400 | + | }) | |
| 401 | + | return _c | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func (_c *RepoService_Update_Call) Return(repo domain.Repo, err error) *RepoService_Update_Call { | |
| 405 | + | _c.Call.Return(repo, err) | |
| 406 | + | return _c | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (_c *RepoService_Update_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.UpdateRepoParams) (domain.Repo, error)) *RepoService_Update_Call { | |
| 410 | + | _c.Call.Return(run) | |
| 411 | + | return _c | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | // View provides a mock function for the type RepoService | |
| 415 | + | func (_mock *RepoService) View(ctx context.Context, repo domain.Repo) (ports.RepoView, error) { | |
| 416 | + | ret := _mock.Called(ctx, repo) | |
| 417 | + | ||
| 418 | + | if len(ret) == 0 { | |
| 419 | + | panic("no return value specified for View") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | var r0 ports.RepoView | |
| 423 | + | var r1 error | |
| 424 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) (ports.RepoView, error)); ok { | |
| 425 | + | return returnFunc(ctx, repo) | |
| 426 | + | } | |
| 427 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Repo) ports.RepoView); ok { | |
| 428 | + | r0 = returnFunc(ctx, repo) | |
| 429 | + | } else { | |
| 430 | + | r0 = ret.Get(0).(ports.RepoView) | |
| 431 | + | } | |
| 432 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Repo) error); ok { | |
| 433 | + | r1 = returnFunc(ctx, repo) | |
| 434 | + | } else { | |
| 435 | + | r1 = ret.Error(1) | |
| 436 | + | } | |
| 437 | + | return r0, r1 | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // RepoService_View_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'View' | |
| 441 | + | type RepoService_View_Call struct { | |
| 442 | + | *mock.Call | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // View is a helper method to define mock.On call | |
| 446 | + | // - ctx context.Context | |
| 447 | + | // - repo domain.Repo | |
| 448 | + | func (_e *RepoService_Expecter) View(ctx any, repo any) *RepoService_View_Call { | |
| 449 | + | return &RepoService_View_Call{Call: _e.mock.On("View", ctx, repo)} | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func (_c *RepoService_View_Call) Run(run func(ctx context.Context, repo domain.Repo)) *RepoService_View_Call { | |
| 453 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 454 | + | var arg0 context.Context | |
| 455 | + | if args[0] != nil { | |
| 456 | + | arg0 = args[0].(context.Context) | |
| 457 | + | } | |
| 458 | + | var arg1 domain.Repo | |
| 459 | + | if args[1] != nil { | |
| 460 | + | arg1 = args[1].(domain.Repo) | |
| 461 | + | } | |
| 462 | + | run( | |
| 463 | + | arg0, | |
| 464 | + | arg1, | |
| 465 | + | ) | |
| 466 | + | }) | |
| 467 | + | return _c | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func (_c *RepoService_View_Call) Return(repoView ports.RepoView, err error) *RepoService_View_Call { | |
| 471 | + | _c.Call.Return(repoView, err) | |
| 472 | + | return _c | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func (_c *RepoService_View_Call) RunAndReturn(run func(ctx context.Context, repo domain.Repo) (ports.RepoView, error)) *RepoService_View_Call { | |
| 476 | + | _c.Call.Return(run) | |
| 477 | + | return _c | |
| 478 | + | } | |
A internal/ports/mocks/Revalidator.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 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 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewRevalidator creates a new instance of Revalidator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewRevalidator(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *Revalidator { | |
| 20 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 21 | + | helper.Helper() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | mock := &Revalidator{} | |
| 25 | + | mock.Mock.Test(t) | |
| 26 | + | ||
| 27 | + | t.Cleanup(func() { | |
| 28 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 29 | + | helper.Helper() | |
| 30 | + | } | |
| 31 | + | mock.AssertExpectations(t) | |
| 32 | + | }) | |
| 33 | + | ||
| 34 | + | return mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Revalidator is an autogenerated mock type for the Revalidator type | |
| 38 | + | type Revalidator struct { | |
| 39 | + | mock.Mock | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Revalidator_Expecter struct { | |
| 43 | + | mock *mock.Mock | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (_m *Revalidator) EXPECT() *Revalidator_Expecter { | |
| 47 | + | return &Revalidator_Expecter{mock: &_m.Mock} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Revalidate provides a mock function for the type Revalidator | |
| 51 | + | func (_mock *Revalidator) Revalidate(ctx context.Context, p domain.Principal) error { | |
| 52 | + | ret := _mock.Called(ctx, p) | |
| 53 | + | ||
| 54 | + | if len(ret) == 0 { | |
| 55 | + | panic("no return value specified for Revalidate") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var r0 error | |
| 59 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |
| 60 | + | r0 = returnFunc(ctx, p) | |
| 61 | + | } else { | |
| 62 | + | r0 = ret.Error(0) | |
| 63 | + | } | |
| 64 | + | return r0 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Revalidator_Revalidate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revalidate' | |
| 68 | + | type Revalidator_Revalidate_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Revalidate is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - p domain.Principal | |
| 75 | + | func (_e *Revalidator_Expecter) Revalidate(ctx any, p any) *Revalidator_Revalidate_Call { | |
| 76 | + | return &Revalidator_Revalidate_Call{Call: _e.mock.On("Revalidate", ctx, p)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *Revalidator_Revalidate_Call) Run(run func(ctx context.Context, p domain.Principal)) *Revalidator_Revalidate_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 domain.Principal | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].(domain.Principal) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *Revalidator_Revalidate_Call) Return(err error) *Revalidator_Revalidate_Call { | |
| 98 | + | _c.Call.Return(err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *Revalidator_Revalidate_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *Revalidator_Revalidate_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
A internal/ports/mocks/SessionService.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 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 | + | // NewSessionService creates a new instance of SessionService. 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 NewSessionService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *SessionService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &SessionService{} | |
| 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 | + | // SessionService is an autogenerated mock type for the SessionService type | |
| 39 | + | type SessionService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type SessionService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *SessionService) EXPECT() *SessionService_Expecter { | |
| 48 | + | return &SessionService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // CreateLoginLink provides a mock function for the type SessionService | |
| 52 | + | func (_mock *SessionService) CreateLoginLink(ctx context.Context, p domain.Principal) (ports.LoginLink, error) { | |
| 53 | + | ret := _mock.Called(ctx, p) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for CreateLoginLink") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.LoginLink | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.LoginLink, error)); ok { | |
| 62 | + | return returnFunc(ctx, p) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.LoginLink); ok { | |
| 65 | + | r0 = returnFunc(ctx, p) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(ports.LoginLink) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // SessionService_CreateLoginLink_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateLoginLink' | |
| 78 | + | type SessionService_CreateLoginLink_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // CreateLoginLink is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | func (_e *SessionService_Expecter) CreateLoginLink(ctx any, p any) *SessionService_CreateLoginLink_Call { | |
| 86 | + | return &SessionService_CreateLoginLink_Call{Call: _e.mock.On("CreateLoginLink", ctx, p)} | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (_c *SessionService_CreateLoginLink_Call) Run(run func(ctx context.Context, p domain.Principal)) *SessionService_CreateLoginLink_Call { | |
| 90 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 91 | + | var arg0 context.Context | |
| 92 | + | if args[0] != nil { | |
| 93 | + | arg0 = args[0].(context.Context) | |
| 94 | + | } | |
| 95 | + | var arg1 domain.Principal | |
| 96 | + | if args[1] != nil { | |
| 97 | + | arg1 = args[1].(domain.Principal) | |
| 98 | + | } | |
| 99 | + | run( | |
| 100 | + | arg0, | |
| 101 | + | arg1, | |
| 102 | + | ) | |
| 103 | + | }) | |
| 104 | + | return _c | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (_c *SessionService_CreateLoginLink_Call) Return(loginLink ports.LoginLink, err error) *SessionService_CreateLoginLink_Call { | |
| 108 | + | _c.Call.Return(loginLink, err) | |
| 109 | + | return _c | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func (_c *SessionService_CreateLoginLink_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.LoginLink, error)) *SessionService_CreateLoginLink_Call { | |
| 113 | + | _c.Call.Return(run) | |
| 114 | + | return _c | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // SignOutEverywhere provides a mock function for the type SessionService | |
| 118 | + | func (_mock *SessionService) SignOutEverywhere(ctx context.Context, p domain.Principal) error { | |
| 119 | + | ret := _mock.Called(ctx, p) | |
| 120 | + | ||
| 121 | + | if len(ret) == 0 { | |
| 122 | + | panic("no return value specified for SignOutEverywhere") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | var r0 error | |
| 126 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) error); ok { | |
| 127 | + | r0 = returnFunc(ctx, p) | |
| 128 | + | } else { | |
| 129 | + | r0 = ret.Error(0) | |
| 130 | + | } | |
| 131 | + | return r0 | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // SessionService_SignOutEverywhere_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignOutEverywhere' | |
| 135 | + | type SessionService_SignOutEverywhere_Call struct { | |
| 136 | + | *mock.Call | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SignOutEverywhere is a helper method to define mock.On call | |
| 140 | + | // - ctx context.Context | |
| 141 | + | // - p domain.Principal | |
| 142 | + | func (_e *SessionService_Expecter) SignOutEverywhere(ctx any, p any) *SessionService_SignOutEverywhere_Call { | |
| 143 | + | return &SessionService_SignOutEverywhere_Call{Call: _e.mock.On("SignOutEverywhere", ctx, p)} | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (_c *SessionService_SignOutEverywhere_Call) Run(run func(ctx context.Context, p domain.Principal)) *SessionService_SignOutEverywhere_Call { | |
| 147 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 148 | + | var arg0 context.Context | |
| 149 | + | if args[0] != nil { | |
| 150 | + | arg0 = args[0].(context.Context) | |
| 151 | + | } | |
| 152 | + | var arg1 domain.Principal | |
| 153 | + | if args[1] != nil { | |
| 154 | + | arg1 = args[1].(domain.Principal) | |
| 155 | + | } | |
| 156 | + | run( | |
| 157 | + | arg0, | |
| 158 | + | arg1, | |
| 159 | + | ) | |
| 160 | + | }) | |
| 161 | + | return _c | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (_c *SessionService_SignOutEverywhere_Call) Return(err error) *SessionService_SignOutEverywhere_Call { | |
| 165 | + | _c.Call.Return(err) | |
| 166 | + | return _c | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (_c *SessionService_SignOutEverywhere_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) error) *SessionService_SignOutEverywhere_Call { | |
| 170 | + | _c.Call.Return(run) | |
| 171 | + | return _c | |
| 172 | + | } | |
A internal/ports/mocks/SigningKeyService.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "bnema.dev/ermite/internal/domain" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewSigningKeyService creates a new instance of SigningKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewSigningKeyService(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *SigningKeyService { | |
| 18 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 19 | + | helper.Helper() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | mock := &SigningKeyService{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { | |
| 26 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 27 | + | helper.Helper() | |
| 28 | + | } | |
| 29 | + | mock.AssertExpectations(t) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | return mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // SigningKeyService is an autogenerated mock type for the SigningKeyService type | |
| 36 | + | type SigningKeyService struct { | |
| 37 | + | mock.Mock | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type SigningKeyService_Expecter struct { | |
| 41 | + | mock *mock.Mock | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (_m *SigningKeyService) EXPECT() *SigningKeyService_Expecter { | |
| 45 | + | return &SigningKeyService_Expecter{mock: &_m.Mock} | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Info provides a mock function for the type SigningKeyService | |
| 49 | + | func (_mock *SigningKeyService) Info() domain.SigningKeyInfo { | |
| 50 | + | ret := _mock.Called() | |
| 51 | + | ||
| 52 | + | if len(ret) == 0 { | |
| 53 | + | panic("no return value specified for Info") | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var r0 domain.SigningKeyInfo | |
| 57 | + | if returnFunc, ok := ret.Get(0).(func() domain.SigningKeyInfo); ok { | |
| 58 | + | r0 = returnFunc() | |
| 59 | + | } else { | |
| 60 | + | r0 = ret.Get(0).(domain.SigningKeyInfo) | |
| 61 | + | } | |
| 62 | + | return r0 | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // SigningKeyService_Info_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Info' | |
| 66 | + | type SigningKeyService_Info_Call struct { | |
| 67 | + | *mock.Call | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Info is a helper method to define mock.On call | |
| 71 | + | func (_e *SigningKeyService_Expecter) Info() *SigningKeyService_Info_Call { | |
| 72 | + | return &SigningKeyService_Info_Call{Call: _e.mock.On("Info")} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (_c *SigningKeyService_Info_Call) Run(run func()) *SigningKeyService_Info_Call { | |
| 76 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 77 | + | run() | |
| 78 | + | }) | |
| 79 | + | return _c | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *SigningKeyService_Info_Call) Return(signingKeyInfo domain.SigningKeyInfo) *SigningKeyService_Info_Call { | |
| 83 | + | _c.Call.Return(signingKeyInfo) | |
| 84 | + | return _c | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func (_c *SigningKeyService_Info_Call) RunAndReturn(run func() domain.SigningKeyInfo) *SigningKeyService_Info_Call { | |
| 88 | + | _c.Call.Return(run) | |
| 89 | + | return _c | |
| 90 | + | } | |
A internal/ports/mocks/TokenService.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 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 | + | // NewTokenService creates a new instance of TokenService. 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 NewTokenService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *TokenService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &TokenService{} | |
| 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 | + | // TokenService is an autogenerated mock type for the TokenService type | |
| 39 | + | type TokenService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type TokenService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *TokenService) EXPECT() *TokenService_Expecter { | |
| 48 | + | return &TokenService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Create provides a mock function for the type TokenService | |
| 52 | + | func (_mock *TokenService) Create(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error) { | |
| 53 | + | ret := _mock.Called(ctx, p, params) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Create") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 ports.CreatedToken | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateTokenParams) (ports.CreatedToken, error)); ok { | |
| 62 | + | return returnFunc(ctx, p, params) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.CreateTokenParams) ports.CreatedToken); ok { | |
| 65 | + | r0 = returnFunc(ctx, p, params) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(ports.CreatedToken) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.CreateTokenParams) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, p, params) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // TokenService_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' | |
| 78 | + | type TokenService_Create_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Create is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - p domain.Principal | |
| 85 | + | // - params ports.CreateTokenParams | |
| 86 | + | func (_e *TokenService_Expecter) Create(ctx any, p any, params any) *TokenService_Create_Call { | |
| 87 | + | return &TokenService_Create_Call{Call: _e.mock.On("Create", ctx, p, params)} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *TokenService_Create_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.CreateTokenParams)) *TokenService_Create_Call { | |
| 91 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 92 | + | var arg0 context.Context | |
| 93 | + | if args[0] != nil { | |
| 94 | + | arg0 = args[0].(context.Context) | |
| 95 | + | } | |
| 96 | + | var arg1 domain.Principal | |
| 97 | + | if args[1] != nil { | |
| 98 | + | arg1 = args[1].(domain.Principal) | |
| 99 | + | } | |
| 100 | + | var arg2 ports.CreateTokenParams | |
| 101 | + | if args[2] != nil { | |
| 102 | + | arg2 = args[2].(ports.CreateTokenParams) | |
| 103 | + | } | |
| 104 | + | run( | |
| 105 | + | arg0, | |
| 106 | + | arg1, | |
| 107 | + | arg2, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *TokenService_Create_Call) Return(createdToken ports.CreatedToken, err error) *TokenService_Create_Call { | |
| 114 | + | _c.Call.Return(createdToken, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *TokenService_Create_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.CreateTokenParams) (ports.CreatedToken, error)) *TokenService_Create_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ListPage provides a mock function for the type TokenService | |
| 124 | + | func (_mock *TokenService) ListPage(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error) { | |
| 125 | + | ret := _mock.Called(ctx, p, size, token) | |
| 126 | + | ||
| 127 | + | if len(ret) == 0 { | |
| 128 | + | panic("no return value specified for ListPage") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | var r0 ports.TokenPage | |
| 132 | + | var r1 error | |
| 133 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.TokenPage, error)); ok { | |
| 134 | + | return returnFunc(ctx, p, size, token) | |
| 135 | + | } | |
| 136 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.TokenPage); ok { | |
| 137 | + | r0 = returnFunc(ctx, p, size, token) | |
| 138 | + | } else { | |
| 139 | + | r0 = ret.Get(0).(ports.TokenPage) | |
| 140 | + | } | |
| 141 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok { | |
| 142 | + | r1 = returnFunc(ctx, p, size, token) | |
| 143 | + | } else { | |
| 144 | + | r1 = ret.Error(1) | |
| 145 | + | } | |
| 146 | + | return r0, r1 | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // TokenService_ListPage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPage' | |
| 150 | + | type TokenService_ListPage_Call struct { | |
| 151 | + | *mock.Call | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // ListPage is a helper method to define mock.On call | |
| 155 | + | // - ctx context.Context | |
| 156 | + | // - p domain.Principal | |
| 157 | + | // - size int | |
| 158 | + | // - token string | |
| 159 | + | func (_e *TokenService_Expecter) ListPage(ctx any, p any, size any, token any) *TokenService_ListPage_Call { | |
| 160 | + | return &TokenService_ListPage_Call{Call: _e.mock.On("ListPage", ctx, p, size, token)} | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (_c *TokenService_ListPage_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *TokenService_ListPage_Call { | |
| 164 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 165 | + | var arg0 context.Context | |
| 166 | + | if args[0] != nil { | |
| 167 | + | arg0 = args[0].(context.Context) | |
| 168 | + | } | |
| 169 | + | var arg1 domain.Principal | |
| 170 | + | if args[1] != nil { | |
| 171 | + | arg1 = args[1].(domain.Principal) | |
| 172 | + | } | |
| 173 | + | var arg2 int | |
| 174 | + | if args[2] != nil { | |
| 175 | + | arg2 = args[2].(int) | |
| 176 | + | } | |
| 177 | + | var arg3 string | |
| 178 | + | if args[3] != nil { | |
| 179 | + | arg3 = args[3].(string) | |
| 180 | + | } | |
| 181 | + | run( | |
| 182 | + | arg0, | |
| 183 | + | arg1, | |
| 184 | + | arg2, | |
| 185 | + | arg3, | |
| 186 | + | ) | |
| 187 | + | }) | |
| 188 | + | return _c | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func (_c *TokenService_ListPage_Call) Return(tokenPage ports.TokenPage, err error) *TokenService_ListPage_Call { | |
| 192 | + | _c.Call.Return(tokenPage, err) | |
| 193 | + | return _c | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *TokenService_ListPage_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.TokenPage, error)) *TokenService_ListPage_Call { | |
| 197 | + | _c.Call.Return(run) | |
| 198 | + | return _c | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // Revoke provides a mock function for the type TokenService | |
| 202 | + | func (_mock *TokenService) Revoke(ctx context.Context, p domain.Principal, id string) error { | |
| 203 | + | ret := _mock.Called(ctx, p, id) | |
| 204 | + | ||
| 205 | + | if len(ret) == 0 { | |
| 206 | + | panic("no return value specified for Revoke") | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | var r0 error | |
| 210 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok { | |
| 211 | + | r0 = returnFunc(ctx, p, id) | |
| 212 | + | } else { | |
| 213 | + | r0 = ret.Error(0) | |
| 214 | + | } | |
| 215 | + | return r0 | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // TokenService_Revoke_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Revoke' | |
| 219 | + | type TokenService_Revoke_Call struct { | |
| 220 | + | *mock.Call | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Revoke is a helper method to define mock.On call | |
| 224 | + | // - ctx context.Context | |
| 225 | + | // - p domain.Principal | |
| 226 | + | // - id string | |
| 227 | + | func (_e *TokenService_Expecter) Revoke(ctx any, p any, id any) *TokenService_Revoke_Call { | |
| 228 | + | return &TokenService_Revoke_Call{Call: _e.mock.On("Revoke", ctx, p, id)} | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (_c *TokenService_Revoke_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *TokenService_Revoke_Call { | |
| 232 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 233 | + | var arg0 context.Context | |
| 234 | + | if args[0] != nil { | |
| 235 | + | arg0 = args[0].(context.Context) | |
| 236 | + | } | |
| 237 | + | var arg1 domain.Principal | |
| 238 | + | if args[1] != nil { | |
| 239 | + | arg1 = args[1].(domain.Principal) | |
| 240 | + | } | |
| 241 | + | var arg2 string | |
| 242 | + | if args[2] != nil { | |
| 243 | + | arg2 = args[2].(string) | |
| 244 | + | } | |
| 245 | + | run( | |
| 246 | + | arg0, | |
| 247 | + | arg1, | |
| 248 | + | arg2, | |
| 249 | + | ) | |
| 250 | + | }) | |
| 251 | + | return _c | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (_c *TokenService_Revoke_Call) Return(err error) *TokenService_Revoke_Call { | |
| 255 | + | _c.Call.Return(err) | |
| 256 | + | return _c | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (_c *TokenService_Revoke_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *TokenService_Revoke_Call { | |
| 260 | + | _c.Call.Return(run) | |
| 261 | + | return _c | |
| 262 | + | } | |
A internal/ports/mocks/WebSessionService.go +244 -0
| @@ -0,0 +1,244 @@ | |||
| 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 | + | "time" | |
| 10 | + | ||
| 11 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewWebSessionService creates a new instance of WebSessionService. 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 NewWebSessionService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *WebSessionService { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &WebSessionService{} | |
| 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 | + | // WebSessionService is an autogenerated mock type for the WebSessionService type | |
| 39 | + | type WebSessionService struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type WebSessionService_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *WebSessionService) EXPECT() *WebSessionService_Expecter { | |
| 48 | + | return &WebSessionService_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Login provides a mock function for the type WebSessionService | |
| 52 | + | func (_mock *WebSessionService) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | |
| 53 | + | ret := _mock.Called(ctx, nonce) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Login") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 string | |
| 60 | + | var r1 time.Duration | |
| 61 | + | var r2 error | |
| 62 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, time.Duration, error)); ok { | |
| 63 | + | return returnFunc(ctx, nonce) | |
| 64 | + | } | |
| 65 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 66 | + | r0 = returnFunc(ctx, nonce) | |
| 67 | + | } else { | |
| 68 | + | r0 = ret.Get(0).(string) | |
| 69 | + | } | |
| 70 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) time.Duration); ok { | |
| 71 | + | r1 = returnFunc(ctx, nonce) | |
| 72 | + | } else { | |
| 73 | + | r1 = ret.Get(1).(time.Duration) | |
| 74 | + | } | |
| 75 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 76 | + | r2 = returnFunc(ctx, nonce) | |
| 77 | + | } else { | |
| 78 | + | r2 = ret.Error(2) | |
| 79 | + | } | |
| 80 | + | return r0, r1, r2 | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // WebSessionService_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login' | |
| 84 | + | type WebSessionService_Login_Call struct { | |
| 85 | + | *mock.Call | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Login is a helper method to define mock.On call | |
| 89 | + | // - ctx context.Context | |
| 90 | + | // - nonce string | |
| 91 | + | func (_e *WebSessionService_Expecter) Login(ctx any, nonce any) *WebSessionService_Login_Call { | |
| 92 | + | return &WebSessionService_Login_Call{Call: _e.mock.On("Login", ctx, nonce)} | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (_c *WebSessionService_Login_Call) Run(run func(ctx context.Context, nonce string)) *WebSessionService_Login_Call { | |
| 96 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 97 | + | var arg0 context.Context | |
| 98 | + | if args[0] != nil { | |
| 99 | + | arg0 = args[0].(context.Context) | |
| 100 | + | } | |
| 101 | + | var arg1 string | |
| 102 | + | if args[1] != nil { | |
| 103 | + | arg1 = args[1].(string) | |
| 104 | + | } | |
| 105 | + | run( | |
| 106 | + | arg0, | |
| 107 | + | arg1, | |
| 108 | + | ) | |
| 109 | + | }) | |
| 110 | + | return _c | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (_c *WebSessionService_Login_Call) Return(token string, lifetime time.Duration, err error) *WebSessionService_Login_Call { | |
| 114 | + | _c.Call.Return(token, lifetime, err) | |
| 115 | + | return _c | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *WebSessionService_Login_Call) RunAndReturn(run func(ctx context.Context, nonce string) (string, time.Duration, error)) *WebSessionService_Login_Call { | |
| 119 | + | _c.Call.Return(run) | |
| 120 | + | return _c | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // LoginLinkValid provides a mock function for the type WebSessionService | |
| 124 | + | func (_mock *WebSessionService) LoginLinkValid(nonce string) bool { | |
| 125 | + | ret := _mock.Called(nonce) | |
| 126 | + | ||
| 127 | + | if len(ret) == 0 { | |
| 128 | + | panic("no return value specified for LoginLinkValid") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | var r0 bool | |
| 132 | + | if returnFunc, ok := ret.Get(0).(func(string) bool); ok { | |
| 133 | + | r0 = returnFunc(nonce) | |
| 134 | + | } else { | |
| 135 | + | r0 = ret.Get(0).(bool) | |
| 136 | + | } | |
| 137 | + | return r0 | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // WebSessionService_LoginLinkValid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoginLinkValid' | |
| 141 | + | type WebSessionService_LoginLinkValid_Call struct { | |
| 142 | + | *mock.Call | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // LoginLinkValid is a helper method to define mock.On call | |
| 146 | + | // - nonce string | |
| 147 | + | func (_e *WebSessionService_Expecter) LoginLinkValid(nonce any) *WebSessionService_LoginLinkValid_Call { | |
| 148 | + | return &WebSessionService_LoginLinkValid_Call{Call: _e.mock.On("LoginLinkValid", nonce)} | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func (_c *WebSessionService_LoginLinkValid_Call) Run(run func(nonce string)) *WebSessionService_LoginLinkValid_Call { | |
| 152 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 153 | + | var arg0 string | |
| 154 | + | if args[0] != nil { | |
| 155 | + | arg0 = args[0].(string) | |
| 156 | + | } | |
| 157 | + | run( | |
| 158 | + | arg0, | |
| 159 | + | ) | |
| 160 | + | }) | |
| 161 | + | return _c | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (_c *WebSessionService_LoginLinkValid_Call) Return(b bool) *WebSessionService_LoginLinkValid_Call { | |
| 165 | + | _c.Call.Return(b) | |
| 166 | + | return _c | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (_c *WebSessionService_LoginLinkValid_Call) RunAndReturn(run func(nonce string) bool) *WebSessionService_LoginLinkValid_Call { | |
| 170 | + | _c.Call.Return(run) | |
| 171 | + | return _c | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Verify provides a mock function for the type WebSessionService | |
| 175 | + | func (_mock *WebSessionService) Verify(ctx context.Context, token string) (domain.Principal, bool, error) { | |
| 176 | + | ret := _mock.Called(ctx, token) | |
| 177 | + | ||
| 178 | + | if len(ret) == 0 { | |
| 179 | + | panic("no return value specified for Verify") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | var r0 domain.Principal | |
| 183 | + | var r1 bool | |
| 184 | + | var r2 error | |
| 185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.Principal, bool, error)); ok { | |
| 186 | + | return returnFunc(ctx, token) | |
| 187 | + | } | |
| 188 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.Principal); ok { | |
| 189 | + | r0 = returnFunc(ctx, token) | |
| 190 | + | } else { | |
| 191 | + | r0 = ret.Get(0).(domain.Principal) | |
| 192 | + | } | |
| 193 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |
| 194 | + | r1 = returnFunc(ctx, token) | |
| 195 | + | } else { | |
| 196 | + | r1 = ret.Get(1).(bool) | |
| 197 | + | } | |
| 198 | + | if returnFunc, ok := ret.Get(2).(func(context.Context, string) error); ok { | |
| 199 | + | r2 = returnFunc(ctx, token) | |
| 200 | + | } else { | |
| 201 | + | r2 = ret.Error(2) | |
| 202 | + | } | |
| 203 | + | return r0, r1, r2 | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // WebSessionService_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' | |
| 207 | + | type WebSessionService_Verify_Call struct { | |
| 208 | + | *mock.Call | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Verify is a helper method to define mock.On call | |
| 212 | + | // - ctx context.Context | |
| 213 | + | // - token string | |
| 214 | + | func (_e *WebSessionService_Expecter) Verify(ctx any, token any) *WebSessionService_Verify_Call { | |
| 215 | + | return &WebSessionService_Verify_Call{Call: _e.mock.On("Verify", ctx, token)} | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (_c *WebSessionService_Verify_Call) Run(run func(ctx context.Context, token string)) *WebSessionService_Verify_Call { | |
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 220 | + | var arg0 context.Context | |
| 221 | + | if args[0] != nil { | |
| 222 | + | arg0 = args[0].(context.Context) | |
| 223 | + | } | |
| 224 | + | var arg1 string | |
| 225 | + | if args[1] != nil { | |
| 226 | + | arg1 = args[1].(string) | |
| 227 | + | } | |
| 228 | + | run( | |
| 229 | + | arg0, | |
| 230 | + | arg1, | |
| 231 | + | ) | |
| 232 | + | }) | |
| 233 | + | return _c | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (_c *WebSessionService_Verify_Call) Return(owner domain.Principal, ok bool, err error) *WebSessionService_Verify_Call { | |
| 237 | + | _c.Call.Return(owner, ok, err) | |
| 238 | + | return _c | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (_c *WebSessionService_Verify_Call) RunAndReturn(run func(ctx context.Context, token string) (domain.Principal, bool, error)) *WebSessionService_Verify_Call { | |
| 242 | + | _c.Call.Return(run) | |
| 243 | + | return _c | |
| 244 | + | } | |
M internal/ports/ports.go +9 -2
| @@ -1,7 +1,14 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | // Package ports declares the interfaces the use cases need from the outside | |
| 4 | - | // world. Adapters implement them; cmd wires them. | |
| 3 | + | // Package ports declares every interface that crosses a layer, and the | |
| 4 | + | // parameter and result types of the use case interfaces. | |
| 5 | + | // | |
| 6 | + | // Outbound ports (ports.go) are what the use cases need from the outside | |
| 7 | + | // world: adapters implement them. Inbound ports (services.go) are the use | |
| 8 | + | // cases as the adapters that serve requests see them: the types of package usecase | |
| 9 | + | // implement them. Adapters and use cases depend on this package and on domain, | |
| 10 | + | // never on each other; internal/app wires the implementations. All the mocks are generated in | |
| 11 | + | // ports/mocks. | |
| 5 | 12 | package ports | |
| 6 | 13 | ||
| 7 | 14 | import ( | |
A internal/ports/services.go +163 -0 collapsed to keep the page light
R internal/app/access.go → internal/usecase/access.go +1 -1 renamed, 99% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "fmt" | |
R internal/app/browse.go → internal/usecase/browse.go +97 -69 collapsed to keep the page light · renamed, 63% similar
R internal/app/browse_test.go → internal/usecase/browse_test.go +57 -44 renamed, 81% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -15,22 +15,22 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/mock" | |
| 16 | 16 | "github.com/stretchr/testify/require" | |
| 17 | 17 | ||
| 18 | - | "bnema.dev/ermite/internal/app" | |
| 19 | 18 | "bnema.dev/ermite/internal/domain" | |
| 20 | 19 | "bnema.dev/ermite/internal/ports" | |
| 21 | 20 | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | "bnema.dev/ermite/internal/usecase" | |
| 22 | 22 | ) | |
| 23 | 23 | ||
| 24 | 24 | type browseFixture struct { | |
| 25 | 25 | meta *mocks.RepoMetaStore | |
| 26 | 26 | reader *mocks.RepoReader | |
| 27 | - | browse *app.Browse | |
| 27 | + | browse *usecase.Browse | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func newBrowseFixture(t *testing.T) *browseFixture { | |
| 31 | 31 | t.Helper() | |
| 32 | 32 | f := &browseFixture{meta: mocks.NewRepoMetaStore(t), reader: mocks.NewRepoReader(t)} | |
| 33 | - | f.browse = app.NewBrowse(f.meta, f.reader) | |
| 33 | + | f.browse = usecase.NewBrowse(f.meta, f.reader) | |
| 34 | 34 | return f | |
| 35 | 35 | } | |
| 36 | 36 | ||
| @@ -46,7 +46,7 @@ func oid(c string) domain.ObjectID { | |||
| 46 | 46 | // The reader expectation of each is what must (not) run. | |
| 47 | 47 | type browseOp struct { | |
| 48 | 48 | name string | |
| 49 | - | call func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error | |
| 49 | + | call func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error | |
| 50 | 50 | // expect registers the reader call a permitted caller makes. | |
| 51 | 51 | expect func(r *mocks.RepoReader) | |
| 52 | 52 | } | |
| @@ -54,35 +54,35 @@ type browseOp struct { | |||
| 54 | 54 | func browseOps() []browseOp { | |
| 55 | 55 | root := domain.TreePath{} | |
| 56 | 56 | return []browseOp{ | |
| 57 | - | {"ListRefs", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 58 | - | _, err := b.ListRefs(ctx, p, repo, app.ListRefsParams{}) | |
| 57 | + | {"ListRefs", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 58 | + | _, err := b.ListRefs(ctx, p, repo, ports.ListRefsParams{}) | |
| 59 | 59 | return err | |
| 60 | 60 | }, func(r *mocks.RepoReader) { r.EXPECT().ListRefs(mock.Anything, mock.Anything).Return(nil, nil) }}, | |
| 61 | - | {"ResolveRevision", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 61 | + | {"ResolveRevision", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 62 | 62 | _, err := b.ResolveRevision(ctx, p, repo, domain.Revision{}) | |
| 63 | 63 | return err | |
| 64 | 64 | }, func(r *mocks.RepoReader) { | |
| 65 | 65 | r.EXPECT().ResolveRevision(mock.Anything, mock.Anything, mock.Anything).Return(domain.ResolvedRevision{}, nil) | |
| 66 | 66 | }}, | |
| 67 | - | {"ResolveRefPath", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 67 | + | {"ResolveRefPath", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 68 | 68 | _, err := b.ResolveRefPath(ctx, p, repo, "main/x") | |
| 69 | 69 | return err | |
| 70 | 70 | }, func(r *mocks.RepoReader) { | |
| 71 | 71 | r.EXPECT().ResolveRefPath(mock.Anything, mock.Anything, "main/x").Return(domain.RefPath{}, nil) | |
| 72 | 72 | }}, | |
| 73 | - | {"GetTree", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 73 | + | {"GetTree", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 74 | 74 | _, err := b.GetTree(ctx, p, repo, domain.Revision{}, root, true) | |
| 75 | 75 | return err | |
| 76 | 76 | }, func(r *mocks.RepoReader) { | |
| 77 | 77 | r.EXPECT().GetTree(mock.Anything, mock.Anything, mock.Anything, mock.Anything, true).Return(domain.Tree{}, nil) | |
| 78 | 78 | }}, | |
| 79 | - | {"ListPaths", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 79 | + | {"ListPaths", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 80 | 80 | _, err := b.ListPaths(ctx, p, repo, domain.Revision{}) | |
| 81 | 81 | return err | |
| 82 | 82 | }, func(r *mocks.RepoReader) { | |
| 83 | 83 | r.EXPECT().ListPaths(mock.Anything, mock.Anything, mock.Anything, domain.MaxPathEntries, domain.MaxPathBytes).Return(domain.PathList{}, nil) | |
| 84 | 84 | }}, | |
| 85 | - | {"OpenBlob", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 85 | + | {"OpenBlob", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 86 | 86 | _, rc, err := b.OpenBlob(ctx, p, repo, domain.Revision{}, root) | |
| 87 | 87 | if rc != nil { | |
| 88 | 88 | _ = rc.Close() | |
| @@ -92,13 +92,13 @@ func browseOps() []browseOp { | |||
| 92 | 92 | r.EXPECT().OpenBlob(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 93 | 93 | Return(domain.BlobInfo{}, io.NopCloser(strings.NewReader("")), nil) | |
| 94 | 94 | }}, | |
| 95 | - | {"ListCommits", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 96 | - | _, err := b.ListCommits(ctx, p, repo, app.ListCommitsParams{}) | |
| 95 | + | {"ListCommits", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 96 | + | _, err := b.ListCommits(ctx, p, repo, ports.ListCommitsParams{}) | |
| 97 | 97 | return err | |
| 98 | 98 | }, func(r *mocks.RepoReader) { | |
| 99 | 99 | r.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything).Return(ports.CommitPage{}, nil) | |
| 100 | 100 | }}, | |
| 101 | - | {"GetCommit", func(b *app.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 101 | + | {"GetCommit", func(b *usecase.Browse, ctx context.Context, p domain.Principal, repo domain.RepoName) error { | |
| 102 | 102 | _, err := b.GetCommit(ctx, p, repo, domain.Revision{}) | |
| 103 | 103 | return err | |
| 104 | 104 | }, func(r *mocks.RepoReader) { | |
| @@ -112,6 +112,7 @@ func browseOps() []browseOp { | |||
| 112 | 112 | // not found; stored tokens need repo:read on the repository; and the Git | |
| 113 | 113 | // reader is never reached when access is refused. | |
| 114 | 114 | func TestBrowseAccess(t *testing.T) { | |
| 115 | + | t.Parallel() | |
| 115 | 116 | ctx := context.Background() | |
| 116 | 117 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 117 | 118 | priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate} | |
| @@ -135,6 +136,7 @@ func TestBrowseAccess(t *testing.T) { | |||
| 135 | 136 | for _, pr := range principals { | |
| 136 | 137 | for _, repo := range []domain.Repo{pub, priv} { | |
| 137 | 138 | t.Run(op.name+"/"+pr.name+"/"+repo.Name.String(), func(t *testing.T) { | |
| 139 | + | t.Parallel() | |
| 138 | 140 | f := newBrowseFixture(t) | |
| 139 | 141 | f.meta.EXPECT().Get(ctx, repo.Name).Return(repo, nil) | |
| 140 | 142 | allowed := pr.pub | |
| @@ -155,12 +157,14 @@ func TestBrowseAccess(t *testing.T) { | |||
| 155 | 157 | } | |
| 156 | 158 | } | |
| 157 | 159 | t.Run(op.name+"/missing repository", func(t *testing.T) { | |
| 160 | + | t.Parallel() | |
| 158 | 161 | f := newBrowseFixture(t) | |
| 159 | 162 | f.meta.EXPECT().Get(ctx, name(t, "nope")).Return(domain.Repo{}, domain.ErrNotFound) | |
| 160 | 163 | err := op.call(f.browse, ctx, domain.Root(), name(t, "nope")) | |
| 161 | 164 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 162 | 165 | }) | |
| 163 | 166 | t.Run(op.name+"/zero name", func(t *testing.T) { | |
| 167 | + | t.Parallel() | |
| 164 | 168 | f := newBrowseFixture(t) | |
| 165 | 169 | err := op.call(f.browse, ctx, domain.Root(), domain.RepoName{}) | |
| 166 | 170 | assert.ErrorIs(t, err, domain.ErrInvalidName) | |
| @@ -169,6 +173,7 @@ func TestBrowseAccess(t *testing.T) { | |||
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | 175 | func TestBrowseHidesPrivateOnReaderErrors(t *testing.T) { | |
| 176 | + | t.Parallel() | |
| 172 | 177 | // A reader error is passed through for a caller who may read. | |
| 173 | 178 | ctx := context.Background() | |
| 174 | 179 | f := newBrowseFixture(t) | |
| @@ -184,6 +189,7 @@ func ref(kind domain.RefKind, name string) domain.Ref { | |||
| 184 | 189 | } | |
| 185 | 190 | ||
| 186 | 191 | func TestListRefsPagingAndKinds(t *testing.T) { | |
| 192 | + | t.Parallel() | |
| 187 | 193 | ctx := context.Background() | |
| 188 | 194 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 189 | 195 | all := []domain.Ref{ | |
| @@ -201,7 +207,7 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 201 | 207 | } | |
| 202 | 208 | return out | |
| 203 | 209 | } | |
| 204 | - | setup := func(t *testing.T, refs []domain.Ref) *app.Browse { | |
| 210 | + | setup := func(t *testing.T, refs []domain.Ref) *usecase.Browse { | |
| 205 | 211 | f := newBrowseFixture(t) | |
| 206 | 212 | f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil) | |
| 207 | 213 | f.reader.EXPECT().ListRefs(mock.Anything, pub.Name).Return(refs, nil) | |
| @@ -214,7 +220,7 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 214 | 220 | tok := "" | |
| 215 | 221 | for pages := 0; ; pages++ { | |
| 216 | 222 | require.Less(t, pages, 10) | |
| 217 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: tok}) | |
| 223 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: tok}) | |
| 218 | 224 | require.NoError(t, err) | |
| 219 | 225 | got = append(got, names(page.Refs)...) | |
| 220 | 226 | if tok = page.NextPageToken; tok == "" { | |
| @@ -226,19 +232,19 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 226 | 232 | ||
| 227 | 233 | t.Run("a ref that appears between pages does not shift the others", func(t *testing.T) { | |
| 228 | 234 | b := setup(t, all[:4]) | |
| 229 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2}) | |
| 235 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2}) | |
| 230 | 236 | require.NoError(t, err) | |
| 231 | 237 | require.Equal(t, []string{"HEAD", "refs/heads/a"}, names(page.Refs)) | |
| 232 | 238 | ||
| 233 | 239 | b2 := setup(t, append([]domain.Ref{all[0], ref(domain.RefKindBranch, "refs/heads/0new")}, all[1:4]...)) | |
| 234 | - | next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken}) | |
| 240 | + | next, err := b2.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 2, PageToken: page.NextPageToken}) | |
| 235 | 241 | require.NoError(t, err) | |
| 236 | 242 | assert.Equal(t, []string{"refs/heads/b", "refs/heads/c"}, names(next.Refs)) | |
| 237 | 243 | }) | |
| 238 | 244 | ||
| 239 | 245 | t.Run("kinds filter", func(t *testing.T) { | |
| 240 | 246 | b := setup(t, all) | |
| 241 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}}) | |
| 247 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{domain.RefKindTag}}) | |
| 242 | 248 | require.NoError(t, err) | |
| 243 | 249 | assert.Equal(t, []string{"refs/tags/v1", "refs/tags/v2"}, names(page.Refs)) | |
| 244 | 250 | assert.Empty(t, page.NextPageToken) | |
| @@ -247,24 +253,26 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 247 | 253 | t.Run("default and maximum page size", func(t *testing.T) { | |
| 248 | 254 | var many []domain.Ref | |
| 249 | 255 | many = append(many, ref(domain.RefKindHead, "HEAD")) | |
| 250 | - | for i := range 250 { | |
| 251 | - | many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%03d", i))) | |
| 256 | + | for i := range usecase.MaxRefPageSize + 100 { | |
| 257 | + | many = append(many, ref(domain.RefKindTag, fmt.Sprintf("refs/tags/t%04d", i))) | |
| 252 | 258 | } | |
| 253 | 259 | b := setup(t, many) | |
| 254 | - | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{}) | |
| 260 | + | page, err := b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{}) | |
| 255 | 261 | require.NoError(t, err) | |
| 256 | - | assert.Len(t, page.Refs, app.DefaultPageSize) | |
| 262 | + | assert.Len(t, page.Refs, usecase.DefaultPageSize) | |
| 257 | 263 | assert.NotEmpty(t, page.NextPageToken) | |
| 258 | 264 | ||
| 259 | 265 | b = setup(t, many) | |
| 260 | - | page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: 100000}) | |
| 266 | + | page, err = b.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: 100000}) | |
| 261 | 267 | require.NoError(t, err) | |
| 262 | - | assert.Len(t, page.Refs, app.MaxPageSize) | |
| 268 | + | assert.Len(t, page.Refs, usecase.MaxRefPageSize, "a page holds up to MaxRefPageSize refs") | |
| 269 | + | assert.NotEmpty(t, page.NextPageToken) | |
| 263 | 270 | }) | |
| 264 | 271 | ||
| 265 | 272 | t.Run("bad input never reaches the reader", func(t *testing.T) { | |
| 273 | + | t.Parallel() | |
| 266 | 274 | f := newBrowseFixture(t) | |
| 267 | - | for _, params := range []app.ListRefsParams{ | |
| 275 | + | for _, params := range []ports.ListRefsParams{ | |
| 268 | 276 | {PageSize: -1}, | |
| 269 | 277 | {Kinds: []domain.RefKind{"nope"}}, | |
| 270 | 278 | {PageToken: "!!!"}, | |
| @@ -273,26 +281,28 @@ func TestListRefsPagingAndKinds(t *testing.T) { | |||
| 273 | 281 | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, params) | |
| 274 | 282 | assert.Error(t, err, "%+v", params) | |
| 275 | 283 | } | |
| 276 | - | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageSize: -1}) | |
| 284 | + | _, err := f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageSize: -1}) | |
| 277 | 285 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 278 | - | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{Kinds: []domain.RefKind{"x"}}) | |
| 286 | + | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{Kinds: []domain.RefKind{"x"}}) | |
| 279 | 287 | assert.ErrorIs(t, err, domain.ErrInvalidRefKind) | |
| 280 | - | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, app.ListRefsParams{PageToken: "!!!"}) | |
| 288 | + | _, err = f.browse.ListRefs(ctx, domain.Anonymous(), pub.Name, ports.ListRefsParams{PageToken: "!!!"}) | |
| 281 | 289 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 282 | 290 | }) | |
| 283 | 291 | } | |
| 284 | 292 | ||
| 285 | 293 | func TestListCommitsPaging(t *testing.T) { | |
| 294 | + | t.Parallel() | |
| 286 | 295 | ctx := context.Background() | |
| 287 | 296 | pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic} | |
| 288 | 297 | p := mustPath(t, "d/f") | |
| 289 | 298 | ||
| 290 | 299 | t.Run("first page asks for one more than the page and returns a token", func(t *testing.T) { | |
| 300 | + | t.Parallel() | |
| 291 | 301 | f := newBrowseFixture(t) | |
| 292 | 302 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | |
| 293 | 303 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Path: p, Limit: 10}). | |
| 294 | 304 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | |
| 295 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageSize: 10}) | |
| 305 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageSize: 10}) | |
| 296 | 306 | require.NoError(t, err) | |
| 297 | 307 | require.Len(t, page.Commits, 1) | |
| 298 | 308 | require.NotEmpty(t, page.NextPageToken) | |
| @@ -304,7 +314,7 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 304 | 314 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | |
| 305 | 315 | // The revision of the request is ignored once there is a token. | |
| 306 | 316 | rev, _ := domain.ParseRevision("moved") | |
| 307 | - | next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{ | |
| 317 | + | next, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{ | |
| 308 | 318 | Revision: rev, Path: p, PageSize: 10, PageToken: page.NextPageToken, | |
| 309 | 319 | }) | |
| 310 | 320 | require.NoError(t, err) | |
| @@ -313,14 +323,15 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 313 | 323 | }) | |
| 314 | 324 | ||
| 315 | 325 | t.Run("a token is bound to its path and must be well formed", func(t *testing.T) { | |
| 326 | + | t.Parallel() | |
| 316 | 327 | f := newBrowseFixture(t) | |
| 317 | 328 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil) | |
| 318 | 329 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |
| 319 | 330 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | |
| 320 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | |
| 331 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p}) | |
| 321 | 332 | require.NoError(t, err) | |
| 322 | 333 | ||
| 323 | - | for _, params := range []app.ListCommitsParams{ | |
| 334 | + | for _, params := range []ports.ListCommitsParams{ | |
| 324 | 335 | {PageToken: page.NextPageToken}, // no path | |
| 325 | 336 | {Path: mustPath(t, "other"), PageToken: page.NextPageToken}, // another path | |
| 326 | 337 | {Path: p, PageToken: "garbage"}, | |
| @@ -333,11 +344,12 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 333 | 344 | }) | |
| 334 | 345 | ||
| 335 | 346 | t.Run("a token cannot skip deeper than the cap", func(t *testing.T) { | |
| 347 | + | t.Parallel() | |
| 336 | 348 | f := newBrowseFixture(t) | |
| 337 | 349 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe() | |
| 338 | 350 | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything). | |
| 339 | 351 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil).Once() | |
| 340 | - | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p}) | |
| 352 | + | page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p}) | |
| 341 | 353 | require.NoError(t, err) | |
| 342 | 354 | raw, err := base64.RawURLEncoding.DecodeString(page.NextPageToken) | |
| 343 | 355 | require.NoError(t, err) | |
| @@ -348,24 +360,25 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 348 | 360 | return base64.RawURLEncoding.EncodeToString([]byte(strings.Join(parts, ":"))) | |
| 349 | 361 | } | |
| 350 | 362 | ||
| 351 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == app.MaxCommitSkip })). | |
| 363 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.MatchedBy(func(q ports.CommitQuery) bool { return q.Skip == usecase.MaxCommitSkip })). | |
| 352 | 364 | Return(ports.CommitPage{Head: oid("a")}, nil).Once() | |
| 353 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip)}) | |
| 365 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip)}) | |
| 354 | 366 | require.NoError(t, err, "the cap itself is allowed") | |
| 355 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{Path: p, PageToken: withSkip(app.MaxCommitSkip + 1)}) | |
| 367 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: p, PageToken: withSkip(usecase.MaxCommitSkip + 1)}) | |
| 356 | 368 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 357 | 369 | }) | |
| 358 | 370 | ||
| 359 | 371 | t.Run("page size", func(t *testing.T) { | |
| 372 | + | t.Parallel() | |
| 360 | 373 | f := newBrowseFixture(t) | |
| 361 | 374 | f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Times(2) | |
| 362 | - | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: app.DefaultPageSize}).Return(ports.CommitPage{}, nil) | |
| 375 | + | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: usecase.DefaultPageSize}).Return(ports.CommitPage{}, nil) | |
| 363 | 376 | f.reader.EXPECT().ListCommits(ctx, pub.Name, ports.CommitQuery{Limit: 100}).Return(ports.CommitPage{}, nil) | |
| 364 | - | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{}) | |
| 377 | + | _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{}) | |
| 365 | 378 | require.NoError(t, err) | |
| 366 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: 5000}) | |
| 379 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: 5000}) | |
| 367 | 380 | require.NoError(t, err) | |
| 368 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, app.ListCommitsParams{PageSize: -3}) | |
| 381 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{PageSize: -3}) | |
| 369 | 382 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 370 | 383 | }) | |
| 371 | 384 | ||
| @@ -375,10 +388,10 @@ func TestListCommitsPaging(t *testing.T) { | |||
| 375 | 388 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | |
| 376 | 389 | f.reader.EXPECT().ListCommits(ctx, priv.Name, mock.Anything). | |
| 377 | 390 | Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("b")}}, HasMore: true}, nil) | |
| 378 | - | page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, app.ListCommitsParams{}) | |
| 391 | + | page, err := f.browse.ListCommits(ctx, domain.Root(), priv.Name, ports.ListCommitsParams{}) | |
| 379 | 392 | require.NoError(t, err) | |
| 380 | 393 | f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil) | |
| 381 | - | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, app.ListCommitsParams{PageToken: page.NextPageToken}) | |
| 394 | + | _, err = f.browse.ListCommits(ctx, domain.Anonymous(), priv.Name, ports.ListCommitsParams{PageToken: page.NextPageToken}) | |
| 382 | 395 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 383 | 396 | }) | |
| 384 | 397 | } | |
A internal/usecase/browsecache.go +166 -0 collapsed to keep the page light
A internal/usecase/browsecache_test.go +360 -0 collapsed to keep the page light
R internal/app/events.go → internal/usecase/events.go +14 -46 collapsed to keep the page light · renamed, 89% similar
A internal/usecase/events_test.go +883 -0 collapsed to keep the page light
A internal/usecase/export_test.go +8 -0
| @@ -0,0 +1,8 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |
| 6 | + | ||
| 7 | + | // AuthorOf exposes authorOf to the tests. | |
| 8 | + | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |
R internal/app/maintenance.go → internal/usecase/maintenance.go +3 -1 renamed, 98% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app | |
| 3 | + | package usecase | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| @@ -38,6 +38,8 @@ type Maintainer struct { | |||
| 38 | 38 | wg sync.WaitGroup | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | var _ ports.MaintenanceScheduler = (*Maintainer)(nil) | |
| 42 | + | ||
| 41 | 43 | type maintEntry struct { | |
| 42 | 44 | timer *time.Timer // pending run, nil while running or idle | |
| 43 | 45 | running bool | |
A internal/usecase/maintenance_test.go +219 -0 collapsed to keep the page light
R internal/app/mergerequests.go → internal/usecase/mergerequests.go +30 -103 collapsed to keep the page light · renamed, 89% similar
R internal/app/mergerequests_delete_test.go → internal/usecase/mergerequests_delete_test.go +26 -15 collapsed to keep the page light · renamed, 77% similar
R internal/app/mergerequests_integration_test.go → internal/usecase/mergerequests_integration_test.go +159 -131 renamed, 75% similar
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | package app_test | |
| 3 | + | package usecase_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| @@ -13,19 +13,39 @@ import ( | |||
| 13 | 13 | "strings" | |
| 14 | 14 | "sync" | |
| 15 | 15 | "testing" | |
| 16 | - | "time" | |
| 17 | 16 | ||
| 18 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 19 | 19 | "github.com/stretchr/testify/require" | |
| 20 | 20 | ||
| 21 | 21 | "bnema.dev/ermite/internal/adapters/eventbus" | |
| 22 | 22 | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 23 | 23 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 24 | 24 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 25 | - | "bnema.dev/ermite/internal/app" | |
| 26 | 25 | "bnema.dev/ermite/internal/domain" | |
| 26 | + | "bnema.dev/ermite/internal/ports" | |
| 27 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 28 | + | "bnema.dev/ermite/internal/usecase" | |
| 27 | 29 | ) | |
| 28 | 30 | ||
| 31 | + | // queuedRepos records the repositories whose maintenance was queued. | |
| 32 | + | type queuedRepos struct { | |
| 33 | + | mu sync.Mutex | |
| 34 | + | rs []domain.RepoName | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (q *queuedRepos) add(_ context.Context, n domain.RepoName) { | |
| 38 | + | q.mu.Lock() | |
| 39 | + | defer q.mu.Unlock() | |
| 40 | + | q.rs = append(q.rs, n) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (q *queuedRepos) names() []domain.RepoName { | |
| 44 | + | q.mu.Lock() | |
| 45 | + | defer q.mu.Unlock() | |
| 46 | + | return append([]domain.RepoName(nil), q.rs...) | |
| 47 | + | } | |
| 48 | + | ||
| 29 | 49 | // mrRig runs the merge request use cases over the real adapters: a data | |
| 30 | 50 | // directory with ermite-kv and bare repositories, and a work tree to push | |
| 31 | 51 | // branches from. | |
| @@ -33,13 +53,14 @@ type mrRig struct { | |||
| 33 | 53 | t *testing.T | |
| 34 | 54 | ctx context.Context | |
| 35 | 55 | data string | |
| 36 | - | repos *app.Repos | |
| 37 | - | mrs *app.MergeRequests | |
| 38 | - | key *app.SigningKey | |
| 56 | + | repos *usecase.Repos | |
| 57 | + | mrs *usecase.MergeRequests | |
| 58 | + | key *usecase.SigningKey | |
| 39 | 59 | store *kvstore.Store | |
| 40 | 60 | git *gitcli.Git | |
| 41 | 61 | bus *eventbus.Bus | |
| 42 | - | sched *scheduler | |
| 62 | + | sched *mocks.MaintenanceScheduler | |
| 63 | + | queued *queuedRepos | |
| 43 | 64 | events <-chan domain.Event | |
| 44 | 65 | work string | |
| 45 | 66 | repo domain.RepoName | |
| @@ -74,18 +95,22 @@ func newMRRig(t *testing.T) *mrRig { | |||
| 74 | 95 | require.NoError(t, err) | |
| 75 | 96 | bus := eventbus.NewWithBuffer(1024) | |
| 76 | 97 | committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"} | |
| 98 | + | sched := mocks.NewMaintenanceScheduler(t) | |
| 99 | + | queued := &queuedRepos{} | |
| 100 | + | sched.EXPECT().Schedule(mock.Anything, mock.Anything).Run(queued.add).Return().Maybe() | |
| 77 | 101 | r := &mrRig{ | |
| 78 | - | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, sched: &scheduler{}, events: bus.Subscribe(ctx), | |
| 79 | - | repos: app.NewRepos(store, git, bus, fixedClock{}), | |
| 80 | - | key: app.NewSigningKey(signer, committer), | |
| 102 | + | sched: sched, queued: queued, | |
| 103 | + | t: t, ctx: ctx, data: data, store: store, git: git, bus: bus, events: bus.Subscribe(ctx), | |
| 104 | + | repos: usecase.NewRepos(store, git, bus, fixedClock{}), | |
| 105 | + | key: usecase.NewSigningKey(signer, committer), | |
| 81 | 106 | work: t.TempDir(), | |
| 82 | 107 | repo: name(t, "proj"), | |
| 83 | 108 | } | |
| 84 | - | r.mrs = app.NewMergeRequests(app.MergeRequestsConfig{ | |
| 109 | + | r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 85 | 110 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 86 | 111 | Committer: committer, Bus: bus, Clock: fixedClock{}, Maint: r.sched, | |
| 87 | 112 | }) | |
| 88 | - | _, err = r.repos.Create(ctx, root, app.CreateParams{Name: r.repo, Visibility: domain.VisibilityPrivate}) | |
| 113 | + | _, err = r.repos.Create(ctx, root, ports.CreateRepoParams{Name: r.repo, Visibility: domain.VisibilityPrivate}) | |
| 89 | 114 | require.NoError(t, err) | |
| 90 | 115 | r.sh("init", "-q", "--initial-branch=main") | |
| 91 | 116 | return r | |
| @@ -172,19 +197,22 @@ func (r *mrRig) feature(branch string) domain.ObjectID { | |||
| 172 | 197 | ||
| 173 | 198 | func (r *mrRig) open(branch string) domain.MergeRequest { | |
| 174 | 199 | r.t.Helper() | |
| 175 | - | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add " + branch, Source: branch}) | |
| 200 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add " + branch, Source: branch}) | |
| 176 | 201 | require.NoError(r.t, err) | |
| 177 | 202 | return mr | |
| 178 | 203 | } | |
| 179 | 204 | ||
| 180 | - | // drain returns the events published so far. | |
| 205 | + | // drain returns the events published so far. The use cases publish on the bus | |
| 206 | + | // from the calling goroutine, and the bus delivers into the buffer of the | |
| 207 | + | // subscriber before Publish returns: whatever a returned call published is | |
| 208 | + | // already in the channel. | |
| 181 | 209 | func (r *mrRig) drain() []domain.Event { | |
| 182 | 210 | var out []domain.Event | |
| 183 | 211 | for { | |
| 184 | 212 | select { | |
| 185 | 213 | case ev := <-r.events: | |
| 186 | 214 | out = append(out, ev) | |
| 187 | - | case <-time.After(20 * time.Millisecond): | |
| 215 | + | default: | |
| 188 | 216 | return out | |
| 189 | 217 | } | |
| 190 | 218 | } | |
| @@ -225,26 +253,26 @@ func TestCreateMergeRequestInputRules(t *testing.T) { | |||
| 225 | 253 | ||
| 226 | 254 | tests := []struct { | |
| 227 | 255 | name string | |
| 228 | - | params app.CreateMergeRequestParams | |
| 256 | + | params ports.CreateMergeRequestParams | |
| 229 | 257 | field string // "" for valid | |
| 230 | 258 | }{ | |
| 231 | - | {"valid, default target", app.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""}, | |
| 232 | - | {"valid, explicit target and description", app.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""}, | |
| 233 | - | {"empty title", app.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"}, | |
| 234 | - | {"two-line title", app.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"}, | |
| 235 | - | {"title too long", app.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"}, | |
| 236 | - | {"title with a bidi override", app.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"}, | |
| 237 | - | {"title with an escape", app.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"}, | |
| 238 | - | {"description with a control character", app.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"}, | |
| 239 | - | {"description with a bidi isolate", app.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"}, | |
| 240 | - | {"description too long", app.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"}, | |
| 241 | - | {"description not utf-8", app.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"}, | |
| 242 | - | {"source missing", app.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"}, | |
| 243 | - | {"source does not exist", app.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"}, | |
| 244 | - | {"source is not a branch name", app.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"}, | |
| 245 | - | {"source is a full ref", app.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"}, | |
| 246 | - | {"target does not exist", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"}, | |
| 247 | - | {"same branch", app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"}, | |
| 259 | + | {"valid, default target", ports.CreateMergeRequestParams{Title: "t", Source: "feat"}, ""}, | |
| 260 | + | {"valid, explicit target and description", ports.CreateMergeRequestParams{Title: "t", Description: "a\nb", Source: "other", Target: "main"}, ""}, | |
| 261 | + | {"empty title", ports.CreateMergeRequestParams{Title: "", Source: "feat", Target: "other"}, "title"}, | |
| 262 | + | {"two-line title", ports.CreateMergeRequestParams{Title: "a\nb", Source: "feat", Target: "other"}, "title"}, | |
| 263 | + | {"title too long", ports.CreateMergeRequestParams{Title: strings.Repeat("x", 121), Source: "feat", Target: "other"}, "title"}, | |
| 264 | + | {"title with a bidi override", ports.CreateMergeRequestParams{Title: "a\u202eb", Source: "feat", Target: "other"}, "title"}, | |
| 265 | + | {"title with an escape", ports.CreateMergeRequestParams{Title: "\x1b[31m", Source: "feat", Target: "other"}, "title"}, | |
| 266 | + | {"description with a control character", ports.CreateMergeRequestParams{Title: "t", Description: "a\x07", Source: "feat", Target: "other"}, "description"}, | |
| 267 | + | {"description with a bidi isolate", ports.CreateMergeRequestParams{Title: "t", Description: "a\u2066b", Source: "feat", Target: "other"}, "description"}, | |
| 268 | + | {"description too long", ports.CreateMergeRequestParams{Title: "t", Description: strings.Repeat("x", 8001), Source: "feat", Target: "other"}, "description"}, | |
| 269 | + | {"description not utf-8", ports.CreateMergeRequestParams{Title: "t", Description: "\xff", Source: "feat", Target: "other"}, "description"}, | |
| 270 | + | {"source missing", ports.CreateMergeRequestParams{Title: "t", Source: ""}, "source_branch"}, | |
| 271 | + | {"source does not exist", ports.CreateMergeRequestParams{Title: "t", Source: "nope", Target: "main"}, "source_branch"}, | |
| 272 | + | {"source is not a branch name", ports.CreateMergeRequestParams{Title: "t", Source: "a..b", Target: "main"}, "source_branch"}, | |
| 273 | + | {"source is a full ref", ports.CreateMergeRequestParams{Title: "t", Source: "refs/heads/feat", Target: "main"}, "source_branch"}, | |
| 274 | + | {"target does not exist", ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "nope"}, "target_branch"}, | |
| 275 | + | {"same branch", ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}, "target_branch"}, | |
| 248 | 276 | } | |
| 249 | 277 | for _, tc := range tests { | |
| 250 | 278 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -263,7 +291,7 @@ func TestCreateMergeRequestInputRules(t *testing.T) { | |||
| 263 | 291 | } | |
| 264 | 292 | ||
| 265 | 293 | t.Run("a request is rejected whole with every field named", func(t *testing.T) { | |
| 266 | - | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"}) | |
| 294 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "a\nb", Description: "\x07", Source: "nope", Target: "feat"}) | |
| 267 | 295 | got := fieldsOf(t, err) | |
| 268 | 296 | assert.Contains(t, got, "title") | |
| 269 | 297 | assert.Contains(t, got, "description") | |
| @@ -282,22 +310,22 @@ func TestCreateMergeRequestNumbersAndDuplicates(t *testing.T) { | |||
| 282 | 310 | second := r.open("two") | |
| 283 | 311 | assert.Equal(t, int64(2), second.Number) | |
| 284 | 312 | ||
| 285 | - | _, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"}) | |
| 313 | + | _, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "again", Source: "one", Target: "main"}) | |
| 286 | 314 | require.ErrorIs(t, err, domain.ErrAlreadyExists, "same open source and target") | |
| 287 | - | _, err = r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "again", Source: "one"}) | |
| 315 | + | _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "again", Source: "one"}) | |
| 288 | 316 | require.ErrorIs(t, err, domain.ErrAlreadyExists, "the default target is the same target") | |
| 289 | 317 | ||
| 290 | 318 | // Another target is another request. | |
| 291 | 319 | r.sh("checkout", "-q", "-B", "stage", "main") | |
| 292 | 320 | r.push("stage") | |
| 293 | - | third, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"}) | |
| 321 | + | third, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to stage", Source: "one", Target: "stage"}) | |
| 294 | 322 | require.NoError(t, err) | |
| 295 | 323 | assert.Equal(t, int64(3), third.Number) | |
| 296 | 324 | ||
| 297 | 325 | // Once the first is closed, the pair is free again, with a new number. | |
| 298 | 326 | _, err = r.mrs.Close(r.ctx, root, r.repo, first.Number) | |
| 299 | 327 | require.NoError(t, err) | |
| 300 | - | fourth, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "retry", Source: "one"}) | |
| 328 | + | fourth, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "retry", Source: "one"}) | |
| 301 | 329 | require.NoError(t, err) | |
| 302 | 330 | assert.Equal(t, int64(4), fourth.Number, "numbers are never reused") | |
| 303 | 331 | } | |
| @@ -313,7 +341,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 313 | 341 | ||
| 314 | 342 | reads := map[string]func(p domain.Principal) error{ | |
| 315 | 343 | "List": func(p domain.Principal) error { | |
| 316 | - | _, err := r.mrs.List(r.ctx, p, r.repo, app.ListMergeRequestsParams{}) | |
| 344 | + | _, err := r.mrs.List(r.ctx, p, r.repo, ports.ListMergeRequestsParams{}) | |
| 317 | 345 | return err | |
| 318 | 346 | }, | |
| 319 | 347 | "Get": func(p domain.Principal) error { | |
| @@ -330,16 +358,16 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 330 | 358 | } | |
| 331 | 359 | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false) | |
| 332 | 360 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | |
| 333 | - | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), app.ListMergeRequestsParams{}) | |
| 361 | + | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{}) | |
| 334 | 362 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | |
| 335 | 363 | ||
| 336 | 364 | writes := map[string]func(p domain.Principal) error{ | |
| 337 | 365 | "Create": func(p domain.Principal) error { | |
| 338 | - | _, err := r.mrs.Create(r.ctx, p, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}) | |
| 366 | + | _, err := r.mrs.Create(r.ctx, p, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "feat"}) | |
| 339 | 367 | return err | |
| 340 | 368 | }, | |
| 341 | 369 | "AddComment": func(p domain.Principal) error { | |
| 342 | - | _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, app.AddCommentParams{Text: "x"}) | |
| 370 | + | _, err := r.mrs.AddComment(r.ctx, p, r.repo, n, ports.AddCommentParams{Text: "x"}) | |
| 343 | 371 | return err | |
| 344 | 372 | }, | |
| 345 | 373 | "ResolveComment": func(p domain.Principal) error { | |
| @@ -355,7 +383,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 355 | 383 | return err | |
| 356 | 384 | }, | |
| 357 | 385 | "Merge": func(p domain.Principal) error { | |
| 358 | - | _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, app.MergeParams{HeadID: mr.HeadID}) | |
| 386 | + | _, err := r.mrs.Merge(r.ctx, p, r.repo, n+100, ports.MergeParams{HeadID: mr.HeadID}) | |
| 359 | 387 | return err | |
| 360 | 388 | }, | |
| 361 | 389 | } | |
| @@ -386,11 +414,11 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 386 | 414 | // On a public repository an anonymous caller reads, but writing is | |
| 387 | 415 | // unauthenticated rather than not found. | |
| 388 | 416 | pubName := name(t, "pub") | |
| 389 | - | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: pubName, Visibility: domain.VisibilityPublic}) | |
| 417 | + | _, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: pubName, Visibility: domain.VisibilityPublic}) | |
| 390 | 418 | require.NoError(t, err) | |
| 391 | - | _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, app.ListMergeRequestsParams{}) | |
| 419 | + | _, err = r.mrs.List(r.ctx, domain.Anonymous(), pubName, ports.ListMergeRequestsParams{}) | |
| 392 | 420 | assert.NoError(t, err) | |
| 393 | - | _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, app.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"}) | |
| 421 | + | _, err = r.mrs.Create(r.ctx, domain.Anonymous(), pubName, ports.CreateMergeRequestParams{Title: "t", Source: "a", Target: "b"}) | |
| 394 | 422 | assert.ErrorIs(t, err, domain.ErrUnauthenticated) | |
| 395 | 423 | } | |
| 396 | 424 | ||
| @@ -407,24 +435,24 @@ func TestListMergeRequests(t *testing.T) { | |||
| 407 | 435 | require.NoError(t, err) | |
| 408 | 436 | r.sh("checkout", "-q", "main") | |
| 409 | 437 | merged := r.feature("f0") | |
| 410 | - | _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, app.MergeParams{HeadID: merged}) | |
| 438 | + | _, err = r.mrs.Merge(r.ctx, root, r.repo, 1, ports.MergeParams{HeadID: merged}) | |
| 411 | 439 | require.NoError(t, err) | |
| 412 | 440 | r.sh("branch", "-D", "f4") | |
| 413 | 441 | r.run(r.bare(), r.env(author), "branch", "-D", "f4") | |
| 414 | 442 | ||
| 415 | - | numbers := func(p app.MergeRequestPage) []int64 { | |
| 443 | + | numbers := func(p ports.MergeRequestPage) []int64 { | |
| 416 | 444 | var out []int64 | |
| 417 | 445 | for _, m := range p.MergeRequests { | |
| 418 | 446 | out = append(out, m.Number) | |
| 419 | 447 | } | |
| 420 | 448 | return out | |
| 421 | 449 | } | |
| 422 | - | all, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 450 | + | all, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{}) | |
| 423 | 451 | require.NoError(t, err) | |
| 424 | 452 | assert.Equal(t, []int64{5, 4, 3, 2, 1}, numbers(all), "highest number first") | |
| 425 | 453 | assert.Empty(t, all.NextPageToken) | |
| 426 | 454 | ||
| 427 | - | open, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateOpen}) | |
| 455 | + | open, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateOpen}) | |
| 428 | 456 | require.NoError(t, err) | |
| 429 | 457 | assert.Equal(t, []int64{5, 4, 3}, numbers(open)) | |
| 430 | 458 | for _, m := range open.MergeRequests { | |
| @@ -434,35 +462,35 @@ func TestListMergeRequests(t *testing.T) { | |||
| 434 | 462 | assert.False(t, m.HeadID.IsZero(), "an open request carries its head") | |
| 435 | 463 | } | |
| 436 | 464 | } | |
| 437 | - | closed, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateClosed}) | |
| 465 | + | closed, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateClosed}) | |
| 438 | 466 | require.NoError(t, err) | |
| 439 | 467 | assert.Equal(t, []int64{2}, numbers(closed)) | |
| 440 | - | mergedPage, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{State: domain.MRStateMerged}) | |
| 468 | + | mergedPage, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{State: domain.MRStateMerged}) | |
| 441 | 469 | require.NoError(t, err) | |
| 442 | 470 | assert.Equal(t, []int64{1}, numbers(mergedPage)) | |
| 443 | 471 | assert.Equal(t, merged, mergedPage.MergeRequests[0].HeadID, "a merged request keeps the head it had") | |
| 444 | 472 | ||
| 445 | 473 | // Paging: the token names the last number. | |
| 446 | - | p1, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2}) | |
| 474 | + | p1, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2}) | |
| 447 | 475 | require.NoError(t, err) | |
| 448 | 476 | assert.Equal(t, []int64{5, 4}, numbers(p1)) | |
| 449 | 477 | require.NotEmpty(t, p1.NextPageToken) | |
| 450 | 478 | // A request created between the pages does not shift them. | |
| 451 | 479 | r.feature("late") | |
| 452 | 480 | r.open("late") | |
| 453 | - | p2, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken}) | |
| 481 | + | p2, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2, PageToken: p1.NextPageToken}) | |
| 454 | 482 | require.NoError(t, err) | |
| 455 | 483 | assert.Equal(t, []int64{3, 2}, numbers(p2)) | |
| 456 | - | p3, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken}) | |
| 484 | + | p3, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: 2, PageToken: p2.NextPageToken}) | |
| 457 | 485 | require.NoError(t, err) | |
| 458 | 486 | assert.Equal(t, []int64{1}, numbers(p3)) | |
| 459 | 487 | assert.Empty(t, p3.NextPageToken) | |
| 460 | 488 | ||
| 461 | 489 | for _, tok := range []string{"garbage", "bW9yZQ"} { | |
| 462 | - | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageToken: tok}) | |
| 490 | + | _, err = r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageToken: tok}) | |
| 463 | 491 | assert.ErrorIs(t, err, domain.ErrInvalidPageToken) | |
| 464 | 492 | } | |
| 465 | - | _, err = r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{PageSize: -1}) | |
| 493 | + | _, err = r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{PageSize: -1}) | |
| 466 | 494 | assert.ErrorIs(t, err, domain.ErrInvalidPageSize) | |
| 467 | 495 | _ = nums | |
| 468 | 496 | } | |
| @@ -472,9 +500,9 @@ func TestGetMergeRequest(t *testing.T) { | |||
| 472 | 500 | main := r.seed() | |
| 473 | 501 | head := r.feature("feat") | |
| 474 | 502 | mr := r.open("feat") | |
| 475 | - | _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "first"}) | |
| 503 | + | _, err := r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "first"}) | |
| 476 | 504 | require.NoError(t, err) | |
| 477 | - | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, app.AddCommentParams{Text: "second"}) | |
| 505 | + | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"}) | |
| 478 | 506 | require.NoError(t, err) | |
| 479 | 507 | ||
| 480 | 508 | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| @@ -515,14 +543,14 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |||
| 515 | 543 | r := newMRRig(t) | |
| 516 | 544 | r.seed() | |
| 517 | 545 | r.sh("checkout", "-q", "-B", "long", "main") | |
| 518 | - | for i := range app.MaxMergeRequestCommits + 5 { | |
| 546 | + | for i := range usecase.MaxMergeRequestCommits + 5 { | |
| 519 | 547 | r.sh("commit", "-q", "--allow-empty", "-m", fmt.Sprintf("c%d", i)) | |
| 520 | 548 | } | |
| 521 | 549 | r.push("long") | |
| 522 | 550 | mr := r.open("long") | |
| 523 | 551 | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 524 | 552 | require.NoError(t, err) | |
| 525 | - | assert.Len(t, d.Commits, app.MaxMergeRequestCommits) | |
| 553 | + | assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits) | |
| 526 | 554 | assert.True(t, d.CommitsTruncated) | |
| 527 | 555 | assert.Equal(t, "c104", d.Commits[0].Summary) | |
| 528 | 556 | } | |
| @@ -538,30 +566,30 @@ func TestAddComment(t *testing.T) { | |||
| 538 | 566 | ||
| 539 | 567 | tests := []struct { | |
| 540 | 568 | name string | |
| 541 | - | params app.AddCommentParams | |
| 569 | + | params ports.AddCommentParams | |
| 542 | 570 | field string // "" for valid | |
| 543 | 571 | }{ | |
| 544 | - | {"general", app.AddCommentParams{Text: "looks good"}, ""}, | |
| 545 | - | {"multi-line general", app.AddCommentParams{Text: "a\n\nb"}, ""}, | |
| 546 | - | {"new side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""}, | |
| 547 | - | {"old side of a changed line", app.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""}, | |
| 548 | - | {"context line", app.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""}, | |
| 549 | - | {"added file", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""}, | |
| 550 | - | {"empty text", app.AddCommentParams{Text: ""}, "text"}, | |
| 551 | - | {"blank text", app.AddCommentParams{Text: " \n "}, "text"}, | |
| 552 | - | {"text too long", app.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"}, | |
| 553 | - | {"text with a control character", app.AddCommentParams{Text: "a\x1b[0m"}, "text"}, | |
| 554 | - | {"text with a tab", app.AddCommentParams{Text: "a\tb"}, "text"}, | |
| 555 | - | {"text with a bidi override", app.AddCommentParams{Text: "a\u202eb"}, "text"}, | |
| 556 | - | {"line outside the diff", app.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 557 | - | {"unchanged file", app.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 558 | - | {"unknown path", app.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 559 | - | {"deleted line on the wrong side", app.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"}, | |
| 560 | - | {"path without a line", app.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"}, | |
| 561 | - | {"path without a side", app.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"}, | |
| 562 | - | {"line without a path", app.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 563 | - | {"side without a path", app.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"}, | |
| 564 | - | {"negative line", app.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"}, | |
| 572 | + | {"general", ports.AddCommentParams{Text: "looks good"}, ""}, | |
| 573 | + | {"multi-line general", ports.AddCommentParams{Text: "a\n\nb"}, ""}, | |
| 574 | + | {"new side of a changed line", ports.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideNew, Text: "x"}, ""}, | |
| 575 | + | {"old side of a changed line", ports.AddCommentParams{Path: "a.txt", Line: 2, Side: domain.SideOld, Text: "x"}, ""}, | |
| 576 | + | {"context line", ports.AddCommentParams{Path: "a.txt", Line: 3, Side: domain.SideNew, Text: "x"}, ""}, | |
| 577 | + | {"added file", ports.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideNew, Text: "x"}, ""}, | |
| 578 | + | {"empty text", ports.AddCommentParams{Text: ""}, "text"}, | |
| 579 | + | {"blank text", ports.AddCommentParams{Text: " \n "}, "text"}, | |
| 580 | + | {"text too long", ports.AddCommentParams{Text: strings.Repeat("x", 8001)}, "text"}, | |
| 581 | + | {"text with a control character", ports.AddCommentParams{Text: "a\x1b[0m"}, "text"}, | |
| 582 | + | {"text with a tab", ports.AddCommentParams{Text: "a\tb"}, "text"}, | |
| 583 | + | {"text with a bidi override", ports.AddCommentParams{Text: "a\u202eb"}, "text"}, | |
| 584 | + | {"line outside the diff", ports.AddCommentParams{Path: "a.txt", Line: 40, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 585 | + | {"unchanged file", ports.AddCommentParams{Path: "b.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 586 | + | {"unknown path", ports.AddCommentParams{Path: "nope.txt", Line: 1, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 587 | + | {"deleted line on the wrong side", ports.AddCommentParams{Path: "c.txt", Line: 1, Side: domain.SideOld, Text: "x"}, "path"}, | |
| 588 | + | {"path without a line", ports.AddCommentParams{Path: "a.txt", Side: domain.SideNew, Text: "x"}, "line"}, | |
| 589 | + | {"path without a side", ports.AddCommentParams{Path: "a.txt", Line: 2, Text: "x"}, "side"}, | |
| 590 | + | {"line without a path", ports.AddCommentParams{Line: 2, Side: domain.SideNew, Text: "x"}, "path"}, | |
| 591 | + | {"side without a path", ports.AddCommentParams{Side: domain.SideOld, Text: "x"}, "path"}, | |
| 592 | + | {"negative line", ports.AddCommentParams{Path: "a.txt", Line: -2, Side: domain.SideNew, Text: "x"}, "line"}, | |
| 565 | 593 | } | |
| 566 | 594 | for _, tc := range tests { | |
| 567 | 595 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -598,7 +626,7 @@ func TestAddComment(t *testing.T) { | |||
| 598 | 626 | t.Run("not open", func(t *testing.T) { | |
| 599 | 627 | _, err := r.mrs.Close(r.ctx, mrW, r.repo, n) | |
| 600 | 628 | require.NoError(t, err) | |
| 601 | - | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, app.AddCommentParams{Text: "late"}) | |
| 629 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Text: "late"}) | |
| 602 | 630 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 603 | 631 | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, n, 1, true) | |
| 604 | 632 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| @@ -615,7 +643,7 @@ func TestConcurrentComments(t *testing.T) { | |||
| 615 | 643 | ids := make(chan int64, workers) | |
| 616 | 644 | for i := range workers { | |
| 617 | 645 | wg.Go(func() { | |
| 618 | - | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 646 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 619 | 647 | if assert.NoError(t, err) { | |
| 620 | 648 | ids <- c.ID | |
| 621 | 649 | } | |
| @@ -639,7 +667,7 @@ func TestResolveComment(t *testing.T) { | |||
| 639 | 667 | r.seed() | |
| 640 | 668 | r.feature("feat") | |
| 641 | 669 | mr := r.open("feat") | |
| 642 | - | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "fix this"}) | |
| 670 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "fix this"}) | |
| 643 | 671 | require.NoError(t, err) | |
| 644 | 672 | r.drain() | |
| 645 | 673 | ||
| @@ -671,9 +699,9 @@ func TestEventsOfAMergeRequest(t *testing.T) { | |||
| 671 | 699 | r.drain() | |
| 672 | 700 | ||
| 673 | 701 | mr := r.open("feat") | |
| 674 | - | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "note"}) | |
| 702 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "note"}) | |
| 675 | 703 | require.NoError(t, err) | |
| 676 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 704 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 677 | 705 | require.NoError(t, err) | |
| 678 | 706 | ||
| 679 | 707 | r.feature("other") | |
| @@ -705,7 +733,7 @@ func TestEventsOfAMergeRequest(t *testing.T) { | |||
| 705 | 733 | // Failed calls publish nothing. | |
| 706 | 734 | _, err = r.mrs.Close(r.ctx, mrW, r.repo, other.Number) | |
| 707 | 735 | require.Error(t, err) | |
| 708 | - | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "x"}) | |
| 736 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "x"}) | |
| 709 | 737 | require.Error(t, err) | |
| 710 | 738 | assert.Empty(t, r.drain()) | |
| 711 | 739 | } | |
| @@ -832,7 +860,7 @@ func TestMergeFastForward(t *testing.T) { | |||
| 832 | 860 | mr := r.open("feat") | |
| 833 | 861 | r.drain() | |
| 834 | 862 | ||
| 835 | - | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 863 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 836 | 864 | require.NoError(t, err) | |
| 837 | 865 | assert.Equal(t, domain.MRStateMerged, merged.State) | |
| 838 | 866 | assert.Equal(t, domain.MergeModeFastForward, merged.MergeMode, "the default mode") | |
| @@ -853,11 +881,11 @@ func TestMergeFastForward(t *testing.T) { | |||
| 853 | 881 | assert.Len(t, d.Diff.Files, 2) | |
| 854 | 882 | ||
| 855 | 883 | // Repeating it, or touching a final request, is a failed precondition. | |
| 856 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 884 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 857 | 885 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 858 | 886 | _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number) | |
| 859 | 887 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 860 | - | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "x"}) | |
| 888 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "x"}) | |
| 861 | 889 | assert.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 862 | 890 | } | |
| 863 | 891 | ||
| @@ -870,14 +898,14 @@ func TestMergeRefusals(t *testing.T) { | |||
| 870 | 898 | mr := r.open("feat") | |
| 871 | 899 | r.commit(author, "more.txt", "m\n", "pushed after the review") | |
| 872 | 900 | r.push("feat") | |
| 873 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 901 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 874 | 902 | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 875 | 903 | assert.Contains(t, err.Error(), "branch moved, read again") | |
| 876 | 904 | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | |
| 877 | 905 | // Reading again gives the new head, which merges. | |
| 878 | 906 | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |
| 879 | 907 | require.NoError(t, err) | |
| 880 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: d.MergeRequest.HeadID}) | |
| 908 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID}) | |
| 881 | 909 | require.NoError(t, err) | |
| 882 | 910 | }) | |
| 883 | 911 | ||
| @@ -892,7 +920,7 @@ func TestMergeRefusals(t *testing.T) { | |||
| 892 | 920 | tip := r.branchHead("main") | |
| 893 | 921 | ||
| 894 | 922 | for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} { | |
| 895 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: mode}) | |
| 923 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 896 | 924 | require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode) | |
| 897 | 925 | assert.Contains(t, err.Error(), "rebase", mode) | |
| 898 | 926 | } | |
| @@ -905,7 +933,7 @@ func TestMergeRefusals(t *testing.T) { | |||
| 905 | 933 | r.sh("checkout", "-q", "feat") | |
| 906 | 934 | r.sh("rebase", "-q", "main") | |
| 907 | 935 | r.push("feat") | |
| 908 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.head()}) | |
| 936 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.head()}) | |
| 909 | 937 | require.NoError(t, err) | |
| 910 | 938 | }) | |
| 911 | 939 | ||
| @@ -915,13 +943,13 @@ func TestMergeRefusals(t *testing.T) { | |||
| 915 | 943 | head := r.feature("feat") | |
| 916 | 944 | mr := r.open("feat") | |
| 917 | 945 | modes := []domain.MergeMode{domain.MergeModeFastForward} | |
| 918 | - | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |
| 946 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes}) | |
| 919 | 947 | require.NoError(t, err) | |
| 920 | 948 | ||
| 921 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 949 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 922 | 950 | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 923 | 951 | assert.Contains(t, err.Error(), "squash") | |
| 924 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 952 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 925 | 953 | require.NoError(t, err, "fast-forward is allowed") | |
| 926 | 954 | }) | |
| 927 | 955 | ||
| @@ -931,11 +959,11 @@ func TestMergeRefusals(t *testing.T) { | |||
| 931 | 959 | head := r.feature("feat") | |
| 932 | 960 | mr := r.open("feat") | |
| 933 | 961 | modes := []domain.MergeMode{domain.MergeModeSquash} | |
| 934 | - | _, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes}) | |
| 962 | + | _, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes}) | |
| 935 | 963 | require.NoError(t, err) | |
| 936 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 964 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 937 | 965 | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 938 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 966 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 939 | 967 | require.NoError(t, err) | |
| 940 | 968 | }) | |
| 941 | 969 | ||
| @@ -944,13 +972,13 @@ func TestMergeRefusals(t *testing.T) { | |||
| 944 | 972 | r.seed() | |
| 945 | 973 | head := r.feature("feat") | |
| 946 | 974 | mr := r.open("feat") | |
| 947 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{}) | |
| 975 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{}) | |
| 948 | 976 | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 949 | 977 | assert.Contains(t, fieldsOf(t, err), "head_id") | |
| 950 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: "rebase"}) | |
| 978 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: "rebase"}) | |
| 951 | 979 | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 952 | 980 | assert.Contains(t, fieldsOf(t, err), "mode") | |
| 953 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, app.MergeParams{HeadID: head}) | |
| 981 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, 99, ports.MergeParams{HeadID: head}) | |
| 954 | 982 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 955 | 983 | }) | |
| 956 | 984 | ||
| @@ -960,13 +988,13 @@ func TestMergeRefusals(t *testing.T) { | |||
| 960 | 988 | head := r.feature("feat") | |
| 961 | 989 | mr := r.open("feat") | |
| 962 | 990 | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |
| 963 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 991 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 964 | 992 | require.ErrorIs(t, err, domain.ErrPreconditionFailed) | |
| 965 | 993 | assert.Contains(t, err.Error(), "no longer exists") | |
| 966 | 994 | ||
| 967 | 995 | r.push("feat") | |
| 968 | 996 | r.run(r.bare(), r.env(author), "update-ref", "refs/heads/main", head.String()) | |
| 969 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head}) | |
| 997 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head}) | |
| 970 | 998 | require.ErrorIs(t, err, domain.ErrPreconditionFailed, "the target already is at the head") | |
| 971 | 999 | assert.Contains(t, err.Error(), "nothing to merge") | |
| 972 | 1000 | }) | |
| @@ -995,7 +1023,7 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||
| 995 | 1023 | continue | |
| 996 | 1024 | } | |
| 997 | 1025 | wg.Go(func() { | |
| 998 | - | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, app.MergeParams{HeadID: head, Mode: mode}) | |
| 1026 | + | _, err := r.mrs.Merge(r.ctx, mrM, r.repo, number, ports.MergeParams{HeadID: head, Mode: mode}) | |
| 999 | 1027 | results <- outcome{number, err} | |
| 1000 | 1028 | }) | |
| 1001 | 1029 | } | |
| @@ -1052,11 +1080,11 @@ func TestSquashMerge(t *testing.T) { | |||
| 1052 | 1080 | r.commit(bob, "w.txt", "w\n", "bob again") | |
| 1053 | 1081 | r.push("feat") | |
| 1054 | 1082 | head := r.branchHead("feat") | |
| 1055 | - | mr, err := r.mrs.Create(r.ctx, root, r.repo, app.CreateMergeRequestParams{Title: "Add things", Source: "feat"}) | |
| 1083 | + | mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "Add things", Source: "feat"}) | |
| 1056 | 1084 | require.NoError(t, err) | |
| 1057 | 1085 | r.drain() | |
| 1058 | 1086 | ||
| 1059 | - | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 1087 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash}) | |
| 1060 | 1088 | require.NoError(t, err) | |
| 1061 | 1089 | assert.Equal(t, domain.MergeModeSquash, merged.MergeMode) | |
| 1062 | 1090 | require.False(t, merged.MergedCommitID.IsZero()) | |
| @@ -1117,7 +1145,7 @@ func TestSquashSingleCommitHasNoTrailer(t *testing.T) { | |||
| 1117 | 1145 | r.commit(author, "m.txt", "m\n", "only") | |
| 1118 | 1146 | r.push("mine") | |
| 1119 | 1147 | mr := r.open("mine") | |
| 1120 | - | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash}) | |
| 1148 | + | merged, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: r.branchHead("mine"), Mode: domain.MergeModeSquash}) | |
| 1121 | 1149 | require.NoError(t, err) | |
| 1122 | 1150 | msg := r.run(r.bare(), r.env(author), "log", "-1", "--format=%B", merged.MergedCommitID.String()) | |
| 1123 | 1151 | assert.Equal(t, "Add mine (!1)\n\n- only", msg) | |
| @@ -1132,7 +1160,7 @@ func TestUpdateRepoSettings(t *testing.T) { | |||
| 1132 | 1160 | ||
| 1133 | 1161 | modes := []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeSquash} | |
| 1134 | 1162 | off := false | |
| 1135 | - | got, err := r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | |
| 1163 | + | got, err := r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &modes, FailingChecksBlock: &off}) | |
| 1136 | 1164 | require.NoError(t, err) | |
| 1137 | 1165 | assert.Equal(t, domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}, got.Settings) | |
| 1138 | 1166 | again, err := r.repos.Get(r.ctx, root, r.repo) | |
| @@ -1141,14 +1169,14 @@ func TestUpdateRepoSettings(t *testing.T) { | |||
| 1141 | 1169 | ||
| 1142 | 1170 | // Only the named setting changes. | |
| 1143 | 1171 | on := true | |
| 1144 | - | got, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{FailingChecksBlock: &on}) | |
| 1172 | + | got, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{FailingChecksBlock: &on}) | |
| 1145 | 1173 | require.NoError(t, err) | |
| 1146 | 1174 | assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got.Settings.AllowedMergeModes) | |
| 1147 | 1175 | assert.True(t, got.Settings.FailingChecksBlock) | |
| 1148 | 1176 | ||
| 1149 | 1177 | for _, bad := range [][]domain.MergeMode{{}, nil, {"rebase"}} { | |
| 1150 | 1178 | bad := bad | |
| 1151 | - | _, err = r.repos.Update(r.ctx, root, r.repo, app.UpdateParams{AllowedMergeModes: &bad}) | |
| 1179 | + | _, err = r.repos.Update(r.ctx, root, r.repo, ports.UpdateRepoParams{AllowedMergeModes: &bad}) | |
| 1152 | 1180 | assert.ErrorIs(t, err, domain.ErrInvalid, "%v", bad) | |
| 1153 | 1181 | } | |
| 1154 | 1182 | again, err = r.repos.Get(r.ctx, root, r.repo) | |
| @@ -1156,7 +1184,7 @@ func TestUpdateRepoSettings(t *testing.T) { | |||
| 1156 | 1184 | assert.Equal(t, got.Settings, again.Settings, "a refused change stores nothing") | |
| 1157 | 1185 | ||
| 1158 | 1186 | // repo:admin is needed, like for any other field. | |
| 1159 | - | _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, app.UpdateParams{FailingChecksBlock: &off}) | |
| 1187 | + | _, err = r.repos.Update(r.ctx, writer(domain.ScopeRepoRead, domain.ScopeMRMerge), r.repo, ports.UpdateRepoParams{FailingChecksBlock: &off}) | |
| 1160 | 1188 | assert.ErrorIs(t, err, domain.ErrForbidden) | |
| 1161 | 1189 | } | |
| 1162 | 1190 | ||
| @@ -1167,13 +1195,13 @@ func TestDeletingARepositoryDropsItsMergeRequests(t *testing.T) { | |||
| 1167 | 1195 | r.open("feat") | |
| 1168 | 1196 | require.NoError(t, r.repos.Delete(r.ctx, root, r.repo)) | |
| 1169 | 1197 | ||
| 1170 | - | _, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 1198 | + | _, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{}) | |
| 1171 | 1199 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1172 | 1200 | ||
| 1173 | 1201 | // Created again under the same name, it starts empty. | |
| 1174 | - | _, err = r.repos.Create(r.ctx, root, app.CreateParams{Name: r.repo}) | |
| 1202 | + | _, err = r.repos.Create(r.ctx, root, ports.CreateRepoParams{Name: r.repo}) | |
| 1175 | 1203 | require.NoError(t, err) | |
| 1176 | - | page, err := r.mrs.List(r.ctx, root, r.repo, app.ListMergeRequestsParams{}) | |
| 1204 | + | page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{}) | |
| 1177 | 1205 | require.NoError(t, err) | |
| 1178 | 1206 | assert.Empty(t, page.MergeRequests) | |
| 1179 | 1207 | } | |
| @@ -1195,12 +1223,12 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) { | |||
| 1195 | 1223 | r.seed() | |
| 1196 | 1224 | head := r.feature("feat") | |
| 1197 | 1225 | ||
| 1198 | - | mr, err := r.mrs.Create(r.ctx, agent, r.repo, app.CreateMergeRequestParams{Title: "From the agent", Source: "feat"}) | |
| 1226 | + | mr, err := r.mrs.Create(r.ctx, agent, r.repo, ports.CreateMergeRequestParams{Title: "From the agent", Source: "feat"}) | |
| 1199 | 1227 | require.NoError(t, err) | |
| 1200 | 1228 | assert.Equal(t, head, mr.HeadID) | |
| 1201 | 1229 | assert.Equal(t, "agent-1", mr.Author) | |
| 1202 | 1230 | ||
| 1203 | - | c, err := r.mrs.AddComment(r.ctx, agent, r.repo, mr.Number, app.AddCommentParams{Text: "note"}) | |
| 1231 | + | c, err := r.mrs.AddComment(r.ctx, agent, r.repo, mr.Number, ports.AddCommentParams{Text: "note"}) | |
| 1204 | 1232 | require.NoError(t, err) | |
| 1205 | 1233 | _, err = r.mrs.ResolveComment(r.ctx, agent, r.repo, mr.Number, c.ID, true) | |
| 1206 | 1234 | require.NoError(t, err) | |
| @@ -1208,7 +1236,7 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) { | |||
| 1208 | 1236 | require.NoError(t, err) | |
| 1209 | 1237 | ||
| 1210 | 1238 | // repo:write alone does not read merge requests. | |
| 1211 | - | _, err = r.mrs.List(r.ctx, agent, r.repo, app.ListMergeRequestsParams{}) | |
| 1239 | + | _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{}) | |
| 1212 | 1240 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1213 | 1241 | _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, false) | |
| 1214 | 1242 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| @@ -1218,11 +1246,11 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) { | |||
| 1218 | 1246 | assert.Equal(t, domain.MRStateClosed, closed.State) | |
| 1219 | 1247 | ||
| 1220 | 1248 | // An unknown repository is not found whatever the scopes. | |
| 1221 | - | _, err = r.mrs.Create(r.ctx, agent, name(t, "ghost"), app.CreateMergeRequestParams{Title: "t", Source: "feat"}) | |
| 1249 | + | _, err = r.mrs.Create(r.ctx, agent, name(t, "ghost"), ports.CreateMergeRequestParams{Title: "t", Source: "feat"}) | |
| 1222 | 1250 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1223 | 1251 | // A token limited to other repositories does not get the scope here. | |
| 1224 | 1252 | elsewhere := domain.TokenPrincipal(domain.Token{ID: "e", Scopes: []domain.Scope{domain.ScopeRepoWrite, domain.ScopeMRWrite}, Repos: []domain.RepoName{name(t, "else")}}) | |
| 1225 | - | _, err = r.mrs.Create(r.ctx, elsewhere, r.repo, app.CreateMergeRequestParams{Title: "t", Source: "feat"}) | |
| 1253 | + | _, err = r.mrs.Create(r.ctx, elsewhere, r.repo, ports.CreateMergeRequestParams{Title: "t", Source: "feat"}) | |
| 1226 | 1254 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1227 | 1255 | } | |
| 1228 | 1256 | ||
| @@ -1238,15 +1266,15 @@ func TestMergeRecordsAPush(t *testing.T) { | |||
| 1238 | 1266 | before, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1239 | 1267 | require.NoError(t, err) | |
| 1240 | 1268 | require.True(t, before.PushedAt.IsZero(), "no push recorded yet") | |
| 1241 | - | assert.Empty(t, r.sched.names) | |
| 1269 | + | assert.Empty(t, r.queued.names()) | |
| 1242 | 1270 | ||
| 1243 | - | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, app.MergeParams{Mode: mode, HeadID: head}) | |
| 1271 | + | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: head}) | |
| 1244 | 1272 | require.NoError(t, err) | |
| 1245 | 1273 | ||
| 1246 | 1274 | after, err := r.repos.Get(r.ctx, root, r.repo) | |
| 1247 | 1275 | require.NoError(t, err) | |
| 1248 | 1276 | assert.Equal(t, now, after.PushedAt) | |
| 1249 | - | assert.Equal(t, []domain.RepoName{r.repo}, r.sched.names, "maintenance queued") | |
| 1277 | + | assert.Equal(t, []domain.RepoName{r.repo}, r.queued.names(), "maintenance queued") | |
| 1250 | 1278 | }) | |
| 1251 | 1279 | } | |
| 1252 | 1280 | } | |
| @@ -1257,15 +1285,15 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||
| 1257 | 1285 | r.seed() | |
| 1258 | 1286 | r.feature("feat") | |
| 1259 | 1287 | mr := r.open("feat") | |
| 1260 | - | for i := range app.MaxCommentsPerMergeRequest { | |
| 1261 | - | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 1288 | + | for i := range usecase.MaxCommentsPerMergeRequest { | |
| 1289 | + | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: fmt.Sprintf("c%d", i)}) | |
| 1262 | 1290 | require.NoError(t, err, "comment %d", i+1) | |
| 1263 | 1291 | } | |
| 1264 | - | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, app.AddCommentParams{Text: "one too many"}) | |
| 1292 | + | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"}) | |
| 1265 | 1293 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 1266 | 1294 | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 1267 | 1295 | require.NoError(t, err) | |
| 1268 | - | assert.Len(t, d.Comments, app.MaxCommentsPerMergeRequest) | |
| 1296 | + | assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest) | |
| 1269 | 1297 | ||
| 1270 | 1298 | // Resolving still works, and another request has its own budget. | |
| 1271 | 1299 | _, err = r.mrs.ResolveComment(r.ctx, mrW, r.repo, mr.Number, 1, true) | |
| @@ -1274,6 +1302,6 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||
| 1274 | 1302 | r.commit(author, "o.txt", "o\n", "other") | |
| 1275 | 1303 | r.push("other") | |
| 1276 | 1304 | other := r.open("other") | |
| 1277 | - | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, app.AddCommentParams{Text: "fine"}) | |
| 1305 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, other.Number, ports.AddCommentParams{Text: "fine"}) | |
| 1278 | 1306 | require.NoError(t, err) | |
| 1279 | 1307 | } | |
R internal/app/mergerequests_test.go → internal/usecase/mergerequests_test.go +17 -8 collapsed to keep the page light · renamed, 85% similar
R internal/app/paging.go → internal/usecase/paging.go +27 -29 collapsed to keep the page light · renamed, 79% similar
R internal/app/pushes.go → internal/usecase/pushes.go +10 -16 collapsed to keep the page light · renamed, 92% similar
R internal/app/pushes_test.go → internal/usecase/pushes_test.go +44 -42 collapsed to keep the page light · renamed, 90% similar
R internal/app/repos.go → internal/usecase/repos.go +5 -11 renamed, 95% similar
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 3 | - | // Package app holds the use cases of ermite. It depends on the domain and on | |
| 3 | + | // Package usecase holds the use cases of ermite. It depends on the domain and on | |
| 4 | 4 | // the ports only. | |
| 5 | - | package app | |
| 5 | + | package usecase | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| @@ -23,20 +23,14 @@ type Repos struct { | |||
| 23 | 23 | clock ports.Clock | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | var _ ports.RepoService = (*Repos)(nil) | |
| 27 | + | ||
| 26 | 28 | // NewRepos builds the repository use cases over their ports. Changes are | |
| 27 | 29 | // published on bus. | |
| 28 | 30 | func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, clock ports.Clock) *Repos { | |
| 29 | 31 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | - | // CreateParams are the settings of a new repository. | |
| 33 | - | type CreateParams struct { | |
| 34 | - | Name domain.RepoName | |
| 35 | - | Description string | |
| 36 | - | // Visibility defaults to private when empty. | |
| 37 | - | Visibility domain.Visibility | |
| 38 | - | } | |
| 39 | - | ||
| 40 | 34 | // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the | |
| 41 | 35 | // metadata (which fails with domain.ErrAlreadyExists if the name is taken, | |
| 42 | 36 | // also when a concurrent Create wins) and publishes the directory. A failure | |
| @@ -45,7 +39,7 @@ type CreateParams struct { | |||
| 45 | 39 | // | |
| 46 | 40 | // The principal needs repo:write on the name: anonymous callers get | |
| 47 | 41 | // domain.ErrUnauthenticated, other callers without it domain.ErrForbidden. | |
| 48 | - | func (r *Repos) Create(ctx context.Context, p domain.Principal, params CreateParams) (domain.Repo, error) { | |
| 42 | + | func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) { | |
| 49 | 43 | if params.Name.IsZero() { | |
| 50 | 44 | return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| 51 | 45 | } | |