detached 67508cc

#4 feat(issues): native issues, owner inbox and moderated public form

state
merged · 67508cc · fast-forward
branch
feat/issue-form → main
opened
bnema ·
updated

Implements ADR 0031: native issues, the owner Inbox, and the moderated public issue form. It contains the three stacked branches feat/issues → feat/inbox → feat/issue-form, and each phase was reviewed before the next.

What it does

  • Issues:
    • Issues share the per-repo number with merge requests, so #n is one or the other and links redirect both ways.
    • New scope issue:write. Agents can create, comment, close and reopen issues without moderation.
    • Per-repo setting issues: off|owner|public, default off. A repo with issues off answers the same 404 as an unknown repo.
    • CLI ermite issue create|list|show|comment|close|reopen and repo edit --issues.
    • Web: an Issues tab, live updates, and owner forms.
  • Inbox (/-/inbox, owner only):
    • Counters at the top, then the sections Awaiting moderation, Open merge requests and Open issues.
    • Repository filter, and an owner live stream across all repositories.
    • Button at the top left of the home page, shown only when signed in.
  • Public form (mode public):
    • JavaScript required. A same-origin worker solves a proof of work signed by the server (18 to 21 bits, adaptive).
    • Honeypot field and a minimum fill time of 3 s.
    • Single-use challenge and form token.
    • Limits of 3/hour and 10/day per network, and a cap of 200 pending issues, both reserved atomically.
    • Bans last 30 days and are keyed on an HMAC of the address. No IP is written to disk.
    • Until the proof of work is valid, every refusal is the same generic 400, so nothing reveals a ban, a limit or a full queue.
    • Moderation (approve, reject, reject and ban) happens in the Inbox and over the API.
    • Approved anonymous issues render untrusted Markdown. Pending issues render as plain text.
  • Storage: schema 3 → 4. The upgrade adds issue:write to stored tokens that already hold every scope, so they keep their owner rights.

Verification

  • Phase reviews (go-reviewer) and a JS review (ts-reviewer). All findings are fixed, including:
    • comment loss after retries;
    • issue events delivered after issues were turned off;
    • the bypass of the per-network limit and of the cap under concurrency;
    • the ban oracle;
    • the emoji/NFC counters;
    • restoring the page from bfcache.
  • Final wrap-up review: every point is fixed except the deferred one listed below.
  • End-to-end test in headless Chromium at production difficulty, measured as:
    • a proof-of-work solve of 0.25 to 4.4 s;
    • moderation, then approval as #1;
    • 403 without Origin, 400 on token reuse, 429 with Retry-After;
    • the no-JS message shown instead of the form.
  • go vet, go test -race ./... (full suite), hexcheck, templ drift, buf generate, the mocks and go mod tidy all pass. All 155 JS tests pass. All commits are signed.

Before leaving draft

  • Merge MR !1 (feat/mr-notes) first. Then rebase this branch on main and extend the v3 → v4 upgrade so that it grants mr:review and issue:write to tokens holding the 9 original scopes. Also restore mr:review in client.AllScopes() and drop the temporary Unimplemented MR note handlers in rpc. Do not deploy main between the two merges: existing all-scope tokens would lose their owner rights.
  • Merge the ermite-api MR (feat/issue-form) and tag it. Replace the local replace bnema.dev/ermite-api with go get bnema.dev/ermite-api@<tag>, then run go mod tidy.
  • Deferred (GO-004, scaling): the home badge and every Inbox event compute the full Overview. This is fine on a solo instance; a lighter count-only path can come later.
  • Behind a reverse proxy, set ERMITE_TRUSTED_PROXIES. Without it, every visitor shares the proxy's per-network limit.
CommitSubjectAuthorDateSignatureStatus
67508cctest(usecase): run the issue moderation subtests in parallelbriceGPG Verified
f6f76c8build(deps): ermite-api v0.9.0briceGPG Verified
7e0e489feat(kvstore): grant mr:review and issue:write to full tokens in the schema 4 upgradebriceGPG Verified
fa37646merge: main with merge request notes into feat/issue-formbriceGPG Verified
346e37fdocs: correct issue access, limiter and comment docsbriceGPG Verified
e53302brefactor(kvstore): keep the hexcheck waiver on issue approvalbriceGPG Verified
ddab9a2fix(rpc): answer not found to non-owners before parsing moderation requestsbriceGPG Verified
f8b1e92refactor: drop the unused pending issue state, ListOpen and InboxMergeRequestbriceGPG Verified
1486f6drefactor(domain): build approved issues in the domainbriceGPG Verified
0284609perf(kvstore): page issues without decoding the whole historybriceGPG Verified
17b5f65fix(kvstore): bound the numbering locksbriceGPG Verified
b0db7adfix(usecase): refresh the inbox after a rejectionbriceGPG Verified
03a1ab8Merge branch 'feat/inbox' into feat/issue-formbriceGPG Verified
0092b37Merge branch 'feat/issues' into feat/inboxbriceGPG Verified
30f7beafix(rpc): build against the api with merge request notes and reject scope mr:reviewbriceGPG Verified
b5344bffix(usecase): verify the proof of work before any admission check and reserve quota and queue atomicallybriceGPG Verified
bc78630fix(web): count issue form limits like the server and refresh the challenge after back navigationbriceGPG Verified
e687d9cdocs: public issue form and moderationbriceGPG Verified
cce8cfafeat(web): public issue form and moderation in the inboxbriceGPG Verified
093dc62feat(web): proof of work solverbriceGPG Verified
3f9aa10feat(web): untrusted markdown for external issuesbriceGPG Verified
99db356feat(usecase): allow public issuesbriceGPG Verified
a26798bfeat(usecase): anonymous issue submission with adaptive proof of workbriceGPG Verified
ee8d39cfeat(rpc): serve pending issue moderation and wire it in the forgebriceGPG Verified
51f0135feat(usecase): add issue moderation, the form subkeys and the owner-only issue.submitted eventbriceGPG Verified
2576c64fix(clientlimit): keep the submission limits free of the domain importbriceGPG Verified
8d2ab49feat(clientlimit): add the sliding window submission limiterbriceGPG Verified
6a3e65dfeat(kvstore): store pending issues, bans and the issue form keybriceGPG Verified
dca1fe7feat(ports): add the pending issue, key, ban, submission limiter and moderation portsbriceGPG Verified
784ea9ffeat(domain): add pending issues, the issue submitted event and the form limitsbriceGPG Verified
babd8c2build(deps): use the local ermite-api issue form branchbriceGPG Verified
d66b108test(events): owner topic access matrix, token end, bearer refusal, stale indexbriceGPG Verified
063865bfix(events): deliver repo.updated and repo.deleted to owner watchers and refresh the inboxbriceGPG Verified
bc6524cfeat(web): owner inbox page and home buttonbriceGPG Verified
ab38ed2feat(events): owner topic across repositoriesbriceGPG Verified
974935dfeat(usecase): owner inbox overview across repositoriesbriceGPG Verified
e17a0e5feat(kvstore): list open merge requests and issues across repositoriesbriceGPG Verified
68460c1fix(kvstore): serialize numbering per repository so concurrent creates never exhaust retriesbriceGPG Verified
b2212f2test(app): merge the declaration with the assignment in the issue e2e test (staticcheck S1021)briceGPG Verified
2794dd4fix(kvstore): guard the repository key when updating an issue so a repo delete leaves no open-issue index keysbriceGPG Verified
02692c4fix(events): stop streaming issue events and views once issues are off or the repo is not visiblebriceGPG Verified
4f0da00fix(app): keep token preset cli working on servers without issues; return issue show write errors; correct issue help limitsbriceGPG Verified
a4f8ebdfix(usecase): report a conflict when every issue comment save conflicts instead of publishing IssueCommentedbriceGPG Verified
e3b6724merge(web): issue pages into feat/issuesbriceGPG Verified
68db1e6feat(web): annotate the issue tab and transition for hexcheckbriceGPG Verified
fd07554docs: document issuesbriceGPG Verified
582cf61feat(cli): add ermite issue commands and repo edit --issuesbriceGPG Verified
931429bfeat(web): add the issue pages, tab, redirects and live noticesbriceGPG Verified
a5d6827feat(rpc): serve IssueService and the issues setting, wire issuesbriceGPG Verified
4c8ce48feat(usecase): add the issue use cases and the issues settingbriceGPG Verified
c52c40ffeat(storage): store issues on the merge request sequence, schema 4briceGPG Verified
b41adf0feat(domain): add issues, the issues mode, text parsers and the issue:write scopebriceGPG Verified
6815c6cbuild(deps): use the local ermite-api issues branchbriceGPG Verified

Checks 8

176 files · +18416 -1025
7/8
M README.md +1 -0
@@ -134,6 +134,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
134134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136136 - [Merge requests](docs/merge-requests.md)
137+- [Issues](docs/issues.md)
137138 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
138139 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
139140 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
@@ -134,6 +134,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
134 - [Git over HTTP](docs/git-http.md): URLs and authentication134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136 - [Merge requests](docs/merge-requests.md)136 - [Merge requests](docs/merge-requests.md)
137+- [Issues](docs/issues.md)
137 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys138 - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
138 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links139 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
139 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows140 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
M docs/cli.md +7 -2
@@ -48,7 +48,7 @@ It does not run:
4848
4949 ```sh
5050 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false]
51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]
5252 ermite repo delete <name> [--yes]
5353
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -59,6 +59,9 @@ ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--
5959 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
6060 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
6161
62+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+
6265 ermite run list <repo> [--workflow …] [--branch …] [--status …]
6366 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
6467 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
@@ -82,9 +85,11 @@ ermite version # client and server versions
8285 ermite completion bash|zsh|fish
8386 ```
8487
88+Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
89+
8590 ## Output
8691
87-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
92+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
8893 - `watch --json` prints one object per line.
8994 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9095 - Shell completion completes repository names from the server.
@@ -48,7 +48,7 @@ It does not run:
48 48
49 ```sh49 ```sh
50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]50 ermite repo list | show <name> | create <name> [--description …] [--visibility public]
51-ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false]51+ermite repo edit <name> [--description …] [--visibility …] [--default-branch …] [--merge-modes …] [--failing-checks-block=false] [--issues off|owner|public]
52 ermite repo delete <name> [--yes]52 ermite repo delete <name> [--yes]
53 53
54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]54 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
@@ -59,6 +59,9 @@ ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--
59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>59 ermite mr resolve <repo> <n> <comment> | edit-comment <repo> <n> <comment> -m … | delete-comment <repo> <n> <comment>
60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>60 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
61 61
62+ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
63+ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
64+
62 ermite run list <repo> [--workflow …] [--branch …] [--status …]65 ermite run list <repo> [--workflow …] [--branch …] [--status …]
63 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]66 ermite run view <repo> <id> [--attempt n] [--log [--job …]] [--summary]
64 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]67 ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
@@ -82,9 +85,11 @@ ermite version # client and server versions
82 ermite completion bash|zsh|fish85 ermite completion bash|zsh|fish
83 ```86 ```
84 87
88+Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
89+
85 ## Output90 ## Output
86 91
87-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.92+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
88 - `watch --json` prints one object per line.93 - `watch --json` prints one object per line.
89 - Errors are one line on stderr (`ermite: …`) with exit status 1.94 - Errors are one line on stderr (`ermite: …`) with exit status 1.
90 - Shell completion completes repository names from the server.95 - Shell completion completes repository names from the server.
M docs/configuration.md +1 -1
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
6666
6767 ## Storage schema
6868
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
@@ -66,4 +66,4 @@ The hook socket and scripts git calls on each push live in `$ERMITE_DATA_DIR/run
66 66
67 ## Storage schema67 ## Storage schema
68 68
69-This build uses storage schema 3. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.69+This build uses storage schema 4. Once it has run on your data, an older ermite refuses to start. To downgrade, restore the backup taken before the upgrade.
A docs/issues.md +55 -0
@@ -0,0 +1,55 @@
1+# Issues
2+
3+An issue records a task, a bug or a question about a repository: a title, a body, comments, and an open or closed state. Issues share the numbering of merge requests: after merge request 1, the next issue is number 2.
4+
5+```sh
6+ermite repo edit myrepo --issues owner
7+ermite issue create myrepo --title "Crash on start" --body "It crashes."
8+ermite issue list myrepo [--state open|closed|all]
9+ermite issue show myrepo 2
10+ermite issue comment myrepo 2 -m "Reproduced."
11+ermite issue close myrepo 2
12+ermite issue reopen myrepo 2
13+```
14+
15+All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.
16+
17+## The `issues` setting
18+
19+`ermite repo edit <repo> --issues off|owner|public`, shown by `repo show` (JSON field `issues`):
20+
21+- `off` (default): issues are disabled.
22+- `owner`: the owner and tokens with `issue:write` write issues.
23+- `public`: anyone may propose an issue through a moderated form (see [Public form and moderation](#public-form-and-moderation)). The repository must be public too.
24+
25+## Who can write
26+
27+The owner, and tokens with the `issue:write` scope. For an agent that proposes changes on its own branches and reports issues:
28+
29+```sh
30+ermite token create claude --preset agent --scope issue:write --repo <repo> --branch-prefix agent/<name>/
31+```
32+
33+## Numbering
34+
35+Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36+
37+## Inbox
38+
39+The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
40+
41+The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
42+
43+## Public form and moderation
44+
45+With `issues` set to `public` on a public repository, a visitor who is not signed in finds a **New issue** button on the Issues tab, which opens `/<repo>/issues/new`. The form needs JavaScript: the page solves a short proof of work in a worker while the visitor writes (a second or so; harder after many recent submissions), and the button stays disabled until it is done and a few seconds have passed. Without JavaScript the form stays hidden and the page says why. A repository that is private, or whose mode is `off` or `owner`, has no such page: it answers the same 404 as an unknown one.
46+
47+Limits: a title of at most 120 characters, a description of at most 10,000, an optional name of at most 40 (not verified). Each network may submit 3 issues per hour and 10 per day. At most 200 submissions await moderation; beyond that the form says submissions are paused. Before the proof of work is valid, nothing reveals whether a network is banned or limited, or whether the queue is full: every such failure gets the same generic refusal. A submission never appears on its own: it is stored as pending and shows no id to the visitor.
48+
49+The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.
50+
51+An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images are never loaded (their alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
52+
53+No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.
54+
55+The limits see the address of the client only if `ERMITE_TRUSTED_PROXIES` lists your reverse proxy; otherwise every visitor shares the proxy's address and one budget (see [configuration](configuration.md)).
@@ -0,0 +1,55 @@
1+# Issues
2+
3+An issue records a task, a bug or a question about a repository: a title, a body, comments, and an open or closed state. Issues share the numbering of merge requests: after merge request 1, the next issue is number 2.
4+
5+```sh
6+ermite repo edit myrepo --issues owner
7+ermite issue create myrepo --title "Crash on start" --body "It crashes."
8+ermite issue list myrepo [--state open|closed|all]
9+ermite issue show myrepo 2
10+ermite issue comment myrepo 2 -m "Reproduced."
11+ermite issue close myrepo 2
12+ermite issue reopen myrepo 2
13+```
14+
15+All commands take `--json`. `--body -` and `--body-file -` read standard input. A server needs the `issues` feature (`ermite version`); without it the commands, `repo edit --issues` and `token create --scope issue:write` are refused.
16+
17+## The `issues` setting
18+
19+`ermite repo edit <repo> --issues off|owner|public`, shown by `repo show` (JSON field `issues`):
20+
21+- `off` (default): issues are disabled.
22+- `owner`: the owner and tokens with `issue:write` write issues.
23+- `public`: anyone may propose an issue through a moderated form (see [Public form and moderation](#public-form-and-moderation)). The repository must be public too.
24+
25+## Who can write
26+
27+The owner, and tokens with the `issue:write` scope. For an agent that proposes changes on its own branches and reports issues:
28+
29+```sh
30+ermite token create claude --preset agent --scope issue:write --repo <repo> --branch-prefix agent/<name>/
31+```
32+
33+## Numbering
34+
35+Merge requests and issues of a repository draw `#n` from one sequence, so a number names one thing: `ermite mr show myrepo 1`, `ermite issue show myrepo 2`.
36+
37+## Inbox
38+
39+The signed-in owner has an Inbox at `/-/inbox`, reached from the **Inbox** button at the top left of the home page, which shows the number of open items. It lists the open merge requests and the open issues of every repository, most recently updated first. The counters at the top (`N merge requests · M issues`) always count everything; `?repo=<name>` restricts the two lists to one repository, and the repository links under the counters set it. Repositories with issues off contribute no issues.
40+
41+The page updates live: merge request and issue events of any repository refresh the counters and the lists. Anyone who is not the signed-in owner, including a token, gets the same 404 as for an unknown page, and the same answer for the `owner` event stream it follows.
42+
43+## Public form and moderation
44+
45+With `issues` set to `public` on a public repository, a visitor who is not signed in finds a **New issue** button on the Issues tab, which opens `/<repo>/issues/new`. The form needs JavaScript: the page solves a short proof of work in a worker while the visitor writes (a second or so; harder after many recent submissions), and the button stays disabled until it is done and a few seconds have passed. Without JavaScript the form stays hidden and the page says why. A repository that is private, or whose mode is `off` or `owner`, has no such page: it answers the same 404 as an unknown one.
46+
47+Limits: a title of at most 120 characters, a description of at most 10,000, an optional name of at most 40 (not verified). Each network may submit 3 issues per hour and 10 per day. At most 200 submissions await moderation; beyond that the form says submissions are paused. Before the proof of work is valid, nothing reveals whether a network is banned or limited, or whether the queue is full: every such failure gets the same generic refusal. A submission never appears on its own: it is stored as pending and shows no id to the visitor.
48+
49+The owner moderates in the Inbox, in the first section, **Awaiting moderation**, whose count is in the counters row. Each submission shows its repository, age, name (or "anonymous"), title and the start of the description, as plain text, with three buttons: **Approve** opens it as the next issue of the repository (numbers are shared with merge requests), **Reject** deletes it, **Reject and ban** deletes it and refuses further submissions from the same network for 30 days. The section updates live when a submission arrives.
50+
51+An approved issue is rendered as untrusted Markdown: raw HTML is dropped, images are never loaded (their alt text is shown), only `http`, `https` and `mailto` links are kept, each with `rel="nofollow noopener noreferrer ugc"`, and every other link loses its link and keeps its text.
52+
53+No IP address is written to disk. The rate limiter keys on the normalized network address (the IPv4 address, or the IPv6 /64) for at most 24 hours and keeps it only in memory. Pending submissions and bans store only a keyed hash (HMAC) of that network, made with a server key.
54+
55+The limits see the address of the client only if `ERMITE_TRUSTED_PROXIES` lists your reverse proxy; otherwise every visitor shares the proxy's address and one budget (see [configuration](configuration.md)).
M docs/tokens.md +2 -1
@@ -6,7 +6,7 @@ ermite token list
66 ermite token revoke <id>
77 ```
88
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
9+The secret is printed once. Presets and `--scope` add up (`issue:write` lets a token write issues). The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
1010
1111 | Preset | For |
1212 |---|---|
@@ -29,6 +29,7 @@ The token **can**:
2929
3030 - push, force-push and delete branches under its prefixes;
3131 - open, comment on and close merge requests; with `mr:review`, give verdicts;
32+- with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
3233 - with `mr:merge`, merge into a branch under its prefixes;
3334 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
3435 - create tokens whose prefixes lie within its own.
@@ -6,7 +6,7 @@ ermite token list
6 ermite token revoke <id>6 ermite token revoke <id>
7 ```7 ```
8 8
9-The secret is printed once. Presets and `--scope` add up. The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.9+The secret is printed once. Presets and `--scope` add up (`issue:write` lets a token write issues). The full scope list is in [`skills/ermite/CLI.md`](../skills/ermite/CLI.md). `mr:review` lets a token give a merge request verdict (`--approve`, `--request-changes`); the `cli` preset includes it, the `agent` preset does not. A token created before `mr:review` existed lacks it: recreate it with `--preset cli` using the root token or from a signed-in owner web session (an old token lacking `mr:review` cannot grant it), then replace the stored CLI secret and revoke the old token to keep the owner's rights.
10 10
11 | Preset | For |11 | Preset | For |
12 |---|---|12 |---|---|
@@ -29,6 +29,7 @@ The token **can**:
29 29
30 - push, force-push and delete branches under its prefixes;30 - push, force-push and delete branches under its prefixes;
31 - open, comment on and close merge requests; with `mr:review`, give verdicts;31 - open, comment on and close merge requests; with `mr:review`, give verdicts;
32+- with `issue:write`, open, comment on, close and reopen issues of the repositories where they are enabled;
32 - with `mr:merge`, merge into a branch under its prefixes;33 - with `mr:merge`, merge into a branch under its prefixes;
33 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;34 - read CI runs, logs and checks; with `ci:write`, cancel and re-run runs of its own branches;
34 - create tokens whose prefixes lie within its own.35 - create tokens whose prefixes lie within its own.
A gen/ermite/storage/issue.pb.go +541 -0
@@ -0,0 +1,541 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+// Code generated by protoc-gen-go. DO NOT EDIT.
6+// versions:
7+// protoc-gen-go v1.36.12
8+// protoc (unknown)
9+// source: ermite/storage/issue.proto
10+
11+package storagepb
12+
13+import (
14+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
15+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
17+ reflect "reflect"
18+ sync "sync"
19+ unsafe "unsafe"
20+)
21+
22+const (
23+ // Verify that this generated code is sufficiently up-to-date.
24+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
25+ // Verify that runtime/protoimpl is sufficiently up-to-date.
26+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
27+)
28+
29+// IssueState is where an issue is in its life.
30+type IssueState int32
31+
32+const (
33+ IssueState_ISSUE_STATE_UNSPECIFIED IssueState = 0
34+ IssueState_ISSUE_STATE_OPEN IssueState = 1
35+ IssueState_ISSUE_STATE_CLOSED IssueState = 2
36+ // Awaiting moderation. Not stored yet.
37+ IssueState_ISSUE_STATE_PENDING IssueState = 3
38+)
39+
40+// Enum value maps for IssueState.
41+var (
42+ IssueState_name = map[int32]string{
43+ 0: "ISSUE_STATE_UNSPECIFIED",
44+ 1: "ISSUE_STATE_OPEN",
45+ 2: "ISSUE_STATE_CLOSED",
46+ 3: "ISSUE_STATE_PENDING",
47+ }
48+ IssueState_value = map[string]int32{
49+ "ISSUE_STATE_UNSPECIFIED": 0,
50+ "ISSUE_STATE_OPEN": 1,
51+ "ISSUE_STATE_CLOSED": 2,
52+ "ISSUE_STATE_PENDING": 3,
53+ }
54+)
55+
56+func (x IssueState) Enum() *IssueState {
57+ p := new(IssueState)
58+ *p = x
59+ return p
60+}
61+
62+func (x IssueState) String() string {
63+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
64+}
65+
66+func (IssueState) Descriptor() protoreflect.EnumDescriptor {
67+ return file_ermite_storage_issue_proto_enumTypes[0].Descriptor()
68+}
69+
70+func (IssueState) Type() protoreflect.EnumType {
71+ return &file_ermite_storage_issue_proto_enumTypes[0]
72+}
73+
74+func (x IssueState) Number() protoreflect.EnumNumber {
75+ return protoreflect.EnumNumber(x)
76+}
77+
78+// Deprecated: Use IssueState.Descriptor instead.
79+func (IssueState) EnumDescriptor() ([]byte, []int) {
80+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0}
81+}
82+
83+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
84+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
85+// indexed under "isopen/<repo>/<number:010d>".
86+type Issue struct {
87+ state protoimpl.MessageState `protogen:"open.v1"`
88+ Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"`
89+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
90+ Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"`
91+ State IssueState `protobuf:"varint,4,opt,name=state,proto3,enum=ermite.storage.IssueState" json:"state,omitempty"`
92+ // Label of the token that opened it.
93+ Author string `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"`
94+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
95+ UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
96+ // Last comment id handed out for this issue.
97+ CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
98+ // True for an issue approved from the public form: its text comes from
99+ // an anonymous visitor and is rendered as untrusted.
100+ External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`
101+ unknownFields protoimpl.UnknownFields
102+ sizeCache protoimpl.SizeCache
103+}
104+
105+func (x *Issue) Reset() {
106+ *x = Issue{}
107+ mi := &file_ermite_storage_issue_proto_msgTypes[0]
108+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
109+ ms.StoreMessageInfo(mi)
110+}
111+
112+func (x *Issue) String() string {
113+ return protoimpl.X.MessageStringOf(x)
114+}
115+
116+func (*Issue) ProtoMessage() {}
117+
118+func (x *Issue) ProtoReflect() protoreflect.Message {
119+ mi := &file_ermite_storage_issue_proto_msgTypes[0]
120+ if x != nil {
121+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
122+ if ms.LoadMessageInfo() == nil {
123+ ms.StoreMessageInfo(mi)
124+ }
125+ return ms
126+ }
127+ return mi.MessageOf(x)
128+}
129+
130+// Deprecated: Use Issue.ProtoReflect.Descriptor instead.
131+func (*Issue) Descriptor() ([]byte, []int) {
132+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0}
133+}
134+
135+func (x *Issue) GetNumber() int64 {
136+ if x != nil {
137+ return x.Number
138+ }
139+ return 0
140+}
141+
142+func (x *Issue) GetTitle() string {
143+ if x != nil {
144+ return x.Title
145+ }
146+ return ""
147+}
148+
149+func (x *Issue) GetBody() string {
150+ if x != nil {
151+ return x.Body
152+ }
153+ return ""
154+}
155+
156+func (x *Issue) GetState() IssueState {
157+ if x != nil {
158+ return x.State
159+ }
160+ return IssueState_ISSUE_STATE_UNSPECIFIED
161+}
162+
163+func (x *Issue) GetAuthor() string {
164+ if x != nil {
165+ return x.Author
166+ }
167+ return ""
168+}
169+
170+func (x *Issue) GetCreatedAt() *timestamppb.Timestamp {
171+ if x != nil {
172+ return x.CreatedAt
173+ }
174+ return nil
175+}
176+
177+func (x *Issue) GetUpdatedAt() *timestamppb.Timestamp {
178+ if x != nil {
179+ return x.UpdatedAt
180+ }
181+ return nil
182+}
183+
184+func (x *Issue) GetCommentSeq() int64 {
185+ if x != nil {
186+ return x.CommentSeq
187+ }
188+ return 0
189+}
190+
191+func (x *Issue) GetExternal() bool {
192+ if x != nil {
193+ return x.External
194+ }
195+ return false
196+}
197+
198+// IssueComment is stored under the key
199+// "isc/<repo>/<number:010d>/<id:010d>".
200+type IssueComment struct {
201+ state protoimpl.MessageState `protogen:"open.v1"`
202+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
203+ Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"`
204+ Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
205+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
206+ unknownFields protoimpl.UnknownFields
207+ sizeCache protoimpl.SizeCache
208+}
209+
210+func (x *IssueComment) Reset() {
211+ *x = IssueComment{}
212+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
213+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
214+ ms.StoreMessageInfo(mi)
215+}
216+
217+func (x *IssueComment) String() string {
218+ return protoimpl.X.MessageStringOf(x)
219+}
220+
221+func (*IssueComment) ProtoMessage() {}
222+
223+func (x *IssueComment) ProtoReflect() protoreflect.Message {
224+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
225+ if x != nil {
226+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
227+ if ms.LoadMessageInfo() == nil {
228+ ms.StoreMessageInfo(mi)
229+ }
230+ return ms
231+ }
232+ return mi.MessageOf(x)
233+}
234+
235+// Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.
236+func (*IssueComment) Descriptor() ([]byte, []int) {
237+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}
238+}
239+
240+func (x *IssueComment) GetId() int64 {
241+ if x != nil {
242+ return x.Id
243+ }
244+ return 0
245+}
246+
247+func (x *IssueComment) GetAuthor() string {
248+ if x != nil {
249+ return x.Author
250+ }
251+ return ""
252+}
253+
254+func (x *IssueComment) GetText() string {
255+ if x != nil {
256+ return x.Text
257+ }
258+ return ""
259+}
260+
261+func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp {
262+ if x != nil {
263+ return x.CreatedAt
264+ }
265+ return nil
266+}
267+
268+// PendingIssue is an issue submitted through the public form, stored under
269+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
270+// It has no number until the owner approves it.
271+type PendingIssue struct {
272+ state protoimpl.MessageState `protogen:"open.v1"`
273+ Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
274+ Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
275+ // Display name given by the submitter. May be empty.
276+ Author string `protobuf:"bytes,3,opt,name=author,proto3" json:"author,omitempty"`
277+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
278+ // HMAC-SHA256 of the client key (32 bytes).
279+ IpHash []byte `protobuf:"bytes,5,opt,name=ip_hash,json=ipHash,proto3" json:"ip_hash,omitempty"`
280+ unknownFields protoimpl.UnknownFields
281+ sizeCache protoimpl.SizeCache
282+}
283+
284+func (x *PendingIssue) Reset() {
285+ *x = PendingIssue{}
286+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
287+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
288+ ms.StoreMessageInfo(mi)
289+}
290+
291+func (x *PendingIssue) String() string {
292+ return protoimpl.X.MessageStringOf(x)
293+}
294+
295+func (*PendingIssue) ProtoMessage() {}
296+
297+func (x *PendingIssue) ProtoReflect() protoreflect.Message {
298+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
299+ if x != nil {
300+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
301+ if ms.LoadMessageInfo() == nil {
302+ ms.StoreMessageInfo(mi)
303+ }
304+ return ms
305+ }
306+ return mi.MessageOf(x)
307+}
308+
309+// Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
310+func (*PendingIssue) Descriptor() ([]byte, []int) {
311+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
312+}
313+
314+func (x *PendingIssue) GetTitle() string {
315+ if x != nil {
316+ return x.Title
317+ }
318+ return ""
319+}
320+
321+func (x *PendingIssue) GetBody() string {
322+ if x != nil {
323+ return x.Body
324+ }
325+ return ""
326+}
327+
328+func (x *PendingIssue) GetAuthor() string {
329+ if x != nil {
330+ return x.Author
331+ }
332+ return ""
333+}
334+
335+func (x *PendingIssue) GetCreatedAt() *timestamppb.Timestamp {
336+ if x != nil {
337+ return x.CreatedAt
338+ }
339+ return nil
340+}
341+
342+func (x *PendingIssue) GetIpHash() []byte {
343+ if x != nil {
344+ return x.IpHash
345+ }
346+ return nil
347+}
348+
349+// IssueBan is stored under the key "isban/<hex of the hash>".
350+type IssueBan struct {
351+ state protoimpl.MessageState `protogen:"open.v1"`
352+ Until *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=until,proto3" json:"until,omitempty"`
353+ unknownFields protoimpl.UnknownFields
354+ sizeCache protoimpl.SizeCache
355+}
356+
357+func (x *IssueBan) Reset() {
358+ *x = IssueBan{}
359+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
360+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
361+ ms.StoreMessageInfo(mi)
362+}
363+
364+func (x *IssueBan) String() string {
365+ return protoimpl.X.MessageStringOf(x)
366+}
367+
368+func (*IssueBan) ProtoMessage() {}
369+
370+func (x *IssueBan) ProtoReflect() protoreflect.Message {
371+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
372+ if x != nil {
373+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
374+ if ms.LoadMessageInfo() == nil {
375+ ms.StoreMessageInfo(mi)
376+ }
377+ return ms
378+ }
379+ return mi.MessageOf(x)
380+}
381+
382+// Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
383+func (*IssueBan) Descriptor() ([]byte, []int) {
384+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
385+}
386+
387+func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
388+ if x != nil {
389+ return x.Until
390+ }
391+ return nil
392+}
393+
394+// IssueKey is stored under the key "meta/issuekey".
395+type IssueKey struct {
396+ state protoimpl.MessageState `protogen:"open.v1"`
397+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
398+ unknownFields protoimpl.UnknownFields
399+ sizeCache protoimpl.SizeCache
400+}
401+
402+func (x *IssueKey) Reset() {
403+ *x = IssueKey{}
404+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
405+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
406+ ms.StoreMessageInfo(mi)
407+}
408+
409+func (x *IssueKey) String() string {
410+ return protoimpl.X.MessageStringOf(x)
411+}
412+
413+func (*IssueKey) ProtoMessage() {}
414+
415+func (x *IssueKey) ProtoReflect() protoreflect.Message {
416+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
417+ if x != nil {
418+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419+ if ms.LoadMessageInfo() == nil {
420+ ms.StoreMessageInfo(mi)
421+ }
422+ return ms
423+ }
424+ return mi.MessageOf(x)
425+}
426+
427+// Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
428+func (*IssueKey) Descriptor() ([]byte, []int) {
429+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
430+}
431+
432+func (x *IssueKey) GetKey() []byte {
433+ if x != nil {
434+ return x.Key
435+ }
436+ return nil
437+}
438+
439+var File_ermite_storage_issue_proto protoreflect.FileDescriptor
440+
441+const file_ermite_storage_issue_proto_rawDesc = "" +
442+ "\n" +
443+ "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" +
444+ "\x05Issue\x12\x16\n" +
445+ "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
446+ "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
447+ "\x04body\x18\x03 \x01(\tR\x04body\x120\n" +
448+ "\x05state\x18\x04 \x01(\x0e2\x1a.ermite.storage.IssueStateR\x05state\x12\x16\n" +
449+ "\x06author\x18\x05 \x01(\tR\x06author\x129\n" +
450+ "\n" +
451+ "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
452+ "\n" +
453+ "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +
454+ "\vcomment_seq\x18\b \x01(\x03R\n" +
455+ "commentSeq\x12\x1a\n" +
456+ "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" +
457+ "\fIssueComment\x12\x0e\n" +
458+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
459+ "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
460+ "\x04text\x18\x03 \x01(\tR\x04text\x129\n" +
461+ "\n" +
462+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa4\x01\n" +
463+ "\fPendingIssue\x12\x14\n" +
464+ "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
465+ "\x04body\x18\x02 \x01(\tR\x04body\x12\x16\n" +
466+ "\x06author\x18\x03 \x01(\tR\x06author\x129\n" +
467+ "\n" +
468+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x17\n" +
469+ "\aip_hash\x18\x05 \x01(\fR\x06ipHash\"<\n" +
470+ "\bIssueBan\x120\n" +
471+ "\x05until\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05until\"\x1c\n" +
472+ "\bIssueKey\x12\x10\n" +
473+ "\x03key\x18\x01 \x01(\fR\x03key*p\n" +
474+ "\n" +
475+ "IssueState\x12\x1b\n" +
476+ "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" +
477+ "\x10ISSUE_STATE_OPEN\x10\x01\x12\x16\n" +
478+ "\x12ISSUE_STATE_CLOSED\x10\x02\x12\x17\n" +
479+ "\x13ISSUE_STATE_PENDING\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
480+
481+var (
482+ file_ermite_storage_issue_proto_rawDescOnce sync.Once
483+ file_ermite_storage_issue_proto_rawDescData []byte
484+)
485+
486+func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
487+ file_ermite_storage_issue_proto_rawDescOnce.Do(func() {
488+ file_ermite_storage_issue_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)))
489+ })
490+ return file_ermite_storage_issue_proto_rawDescData
491+}
492+
493+var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
494+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
495+var file_ermite_storage_issue_proto_goTypes = []any{
496+ (IssueState)(0), // 0: ermite.storage.IssueState
497+ (*Issue)(nil), // 1: ermite.storage.Issue
498+ (*IssueComment)(nil), // 2: ermite.storage.IssueComment
499+ (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue
500+ (*IssueBan)(nil), // 4: ermite.storage.IssueBan
501+ (*IssueKey)(nil), // 5: ermite.storage.IssueKey
502+ (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
503+}
504+var file_ermite_storage_issue_proto_depIdxs = []int32{
505+ 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
506+ 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
507+ 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
508+ 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
509+ 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
510+ 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
511+ 6, // [6:6] is the sub-list for method output_type
512+ 6, // [6:6] is the sub-list for method input_type
513+ 6, // [6:6] is the sub-list for extension type_name
514+ 6, // [6:6] is the sub-list for extension extendee
515+ 0, // [0:6] is the sub-list for field type_name
516+}
517+
518+func init() { file_ermite_storage_issue_proto_init() }
519+func file_ermite_storage_issue_proto_init() {
520+ if File_ermite_storage_issue_proto != nil {
521+ return
522+ }
523+ type x struct{}
524+ out := protoimpl.TypeBuilder{
525+ File: protoimpl.DescBuilder{
526+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
527+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
528+ NumEnums: 1,
529+ NumMessages: 5,
530+ NumExtensions: 0,
531+ NumServices: 0,
532+ },
533+ GoTypes: file_ermite_storage_issue_proto_goTypes,
534+ DependencyIndexes: file_ermite_storage_issue_proto_depIdxs,
535+ EnumInfos: file_ermite_storage_issue_proto_enumTypes,
536+ MessageInfos: file_ermite_storage_issue_proto_msgTypes,
537+ }.Build()
538+ File_ermite_storage_issue_proto = out.File
539+ file_ermite_storage_issue_proto_goTypes = nil
540+ file_ermite_storage_issue_proto_depIdxs = nil
541+}
@@ -0,0 +1,541 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: issues.
4+
5+// Code generated by protoc-gen-go. DO NOT EDIT.
6+// versions:
7+// protoc-gen-go v1.36.12
8+// protoc (unknown)
9+// source: ermite/storage/issue.proto
10+
11+package storagepb
12+
13+import (
14+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
15+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
16+ timestamppb "google.golang.org/protobuf/types/known/timestamppb"
17+ reflect "reflect"
18+ sync "sync"
19+ unsafe "unsafe"
20+)
21+
22+const (
23+ // Verify that this generated code is sufficiently up-to-date.
24+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
25+ // Verify that runtime/protoimpl is sufficiently up-to-date.
26+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
27+)
28+
29+// IssueState is where an issue is in its life.
30+type IssueState int32
31+
32+const (
33+ IssueState_ISSUE_STATE_UNSPECIFIED IssueState = 0
34+ IssueState_ISSUE_STATE_OPEN IssueState = 1
35+ IssueState_ISSUE_STATE_CLOSED IssueState = 2
36+ // Awaiting moderation. Not stored yet.
37+ IssueState_ISSUE_STATE_PENDING IssueState = 3
38+)
39+
40+// Enum value maps for IssueState.
41+var (
42+ IssueState_name = map[int32]string{
43+ 0: "ISSUE_STATE_UNSPECIFIED",
44+ 1: "ISSUE_STATE_OPEN",
45+ 2: "ISSUE_STATE_CLOSED",
46+ 3: "ISSUE_STATE_PENDING",
47+ }
48+ IssueState_value = map[string]int32{
49+ "ISSUE_STATE_UNSPECIFIED": 0,
50+ "ISSUE_STATE_OPEN": 1,
51+ "ISSUE_STATE_CLOSED": 2,
52+ "ISSUE_STATE_PENDING": 3,
53+ }
54+)
55+
56+func (x IssueState) Enum() *IssueState {
57+ p := new(IssueState)
58+ *p = x
59+ return p
60+}
61+
62+func (x IssueState) String() string {
63+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
64+}
65+
66+func (IssueState) Descriptor() protoreflect.EnumDescriptor {
67+ return file_ermite_storage_issue_proto_enumTypes[0].Descriptor()
68+}
69+
70+func (IssueState) Type() protoreflect.EnumType {
71+ return &file_ermite_storage_issue_proto_enumTypes[0]
72+}
73+
74+func (x IssueState) Number() protoreflect.EnumNumber {
75+ return protoreflect.EnumNumber(x)
76+}
77+
78+// Deprecated: Use IssueState.Descriptor instead.
79+func (IssueState) EnumDescriptor() ([]byte, []int) {
80+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0}
81+}
82+
83+// Issue is stored under the key "is/<repo>/<number:010d>". The numbers come
84+// from the sequence of the merge requests ("mrseq/<repo>"). The open ones are
85+// indexed under "isopen/<repo>/<number:010d>".
86+type Issue struct {
87+ state protoimpl.MessageState `protogen:"open.v1"`
88+ Number int64 `protobuf:"varint,1,opt,name=number,proto3" json:"number,omitempty"`
89+ Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
90+ Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"`
91+ State IssueState `protobuf:"varint,4,opt,name=state,proto3,enum=ermite.storage.IssueState" json:"state,omitempty"`
92+ // Label of the token that opened it.
93+ Author string `protobuf:"bytes,5,opt,name=author,proto3" json:"author,omitempty"`
94+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
95+ UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
96+ // Last comment id handed out for this issue.
97+ CommentSeq int64 `protobuf:"varint,8,opt,name=comment_seq,json=commentSeq,proto3" json:"comment_seq,omitempty"`
98+ // True for an issue approved from the public form: its text comes from
99+ // an anonymous visitor and is rendered as untrusted.
100+ External bool `protobuf:"varint,9,opt,name=external,proto3" json:"external,omitempty"`
101+ unknownFields protoimpl.UnknownFields
102+ sizeCache protoimpl.SizeCache
103+}
104+
105+func (x *Issue) Reset() {
106+ *x = Issue{}
107+ mi := &file_ermite_storage_issue_proto_msgTypes[0]
108+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
109+ ms.StoreMessageInfo(mi)
110+}
111+
112+func (x *Issue) String() string {
113+ return protoimpl.X.MessageStringOf(x)
114+}
115+
116+func (*Issue) ProtoMessage() {}
117+
118+func (x *Issue) ProtoReflect() protoreflect.Message {
119+ mi := &file_ermite_storage_issue_proto_msgTypes[0]
120+ if x != nil {
121+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
122+ if ms.LoadMessageInfo() == nil {
123+ ms.StoreMessageInfo(mi)
124+ }
125+ return ms
126+ }
127+ return mi.MessageOf(x)
128+}
129+
130+// Deprecated: Use Issue.ProtoReflect.Descriptor instead.
131+func (*Issue) Descriptor() ([]byte, []int) {
132+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{0}
133+}
134+
135+func (x *Issue) GetNumber() int64 {
136+ if x != nil {
137+ return x.Number
138+ }
139+ return 0
140+}
141+
142+func (x *Issue) GetTitle() string {
143+ if x != nil {
144+ return x.Title
145+ }
146+ return ""
147+}
148+
149+func (x *Issue) GetBody() string {
150+ if x != nil {
151+ return x.Body
152+ }
153+ return ""
154+}
155+
156+func (x *Issue) GetState() IssueState {
157+ if x != nil {
158+ return x.State
159+ }
160+ return IssueState_ISSUE_STATE_UNSPECIFIED
161+}
162+
163+func (x *Issue) GetAuthor() string {
164+ if x != nil {
165+ return x.Author
166+ }
167+ return ""
168+}
169+
170+func (x *Issue) GetCreatedAt() *timestamppb.Timestamp {
171+ if x != nil {
172+ return x.CreatedAt
173+ }
174+ return nil
175+}
176+
177+func (x *Issue) GetUpdatedAt() *timestamppb.Timestamp {
178+ if x != nil {
179+ return x.UpdatedAt
180+ }
181+ return nil
182+}
183+
184+func (x *Issue) GetCommentSeq() int64 {
185+ if x != nil {
186+ return x.CommentSeq
187+ }
188+ return 0
189+}
190+
191+func (x *Issue) GetExternal() bool {
192+ if x != nil {
193+ return x.External
194+ }
195+ return false
196+}
197+
198+// IssueComment is stored under the key
199+// "isc/<repo>/<number:010d>/<id:010d>".
200+type IssueComment struct {
201+ state protoimpl.MessageState `protogen:"open.v1"`
202+ Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
203+ Author string `protobuf:"bytes,2,opt,name=author,proto3" json:"author,omitempty"`
204+ Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
205+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
206+ unknownFields protoimpl.UnknownFields
207+ sizeCache protoimpl.SizeCache
208+}
209+
210+func (x *IssueComment) Reset() {
211+ *x = IssueComment{}
212+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
213+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
214+ ms.StoreMessageInfo(mi)
215+}
216+
217+func (x *IssueComment) String() string {
218+ return protoimpl.X.MessageStringOf(x)
219+}
220+
221+func (*IssueComment) ProtoMessage() {}
222+
223+func (x *IssueComment) ProtoReflect() protoreflect.Message {
224+ mi := &file_ermite_storage_issue_proto_msgTypes[1]
225+ if x != nil {
226+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
227+ if ms.LoadMessageInfo() == nil {
228+ ms.StoreMessageInfo(mi)
229+ }
230+ return ms
231+ }
232+ return mi.MessageOf(x)
233+}
234+
235+// Deprecated: Use IssueComment.ProtoReflect.Descriptor instead.
236+func (*IssueComment) Descriptor() ([]byte, []int) {
237+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{1}
238+}
239+
240+func (x *IssueComment) GetId() int64 {
241+ if x != nil {
242+ return x.Id
243+ }
244+ return 0
245+}
246+
247+func (x *IssueComment) GetAuthor() string {
248+ if x != nil {
249+ return x.Author
250+ }
251+ return ""
252+}
253+
254+func (x *IssueComment) GetText() string {
255+ if x != nil {
256+ return x.Text
257+ }
258+ return ""
259+}
260+
261+func (x *IssueComment) GetCreatedAt() *timestamppb.Timestamp {
262+ if x != nil {
263+ return x.CreatedAt
264+ }
265+ return nil
266+}
267+
268+// PendingIssue is an issue submitted through the public form, stored under
269+// the key "ispend/<repo>/<id>" where id is 16 random bytes in lowercase base32.
270+// It has no number until the owner approves it.
271+type PendingIssue struct {
272+ state protoimpl.MessageState `protogen:"open.v1"`
273+ Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
274+ Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
275+ // Display name given by the submitter. May be empty.
276+ Author string `protobuf:"bytes,3,opt,name=author,proto3" json:"author,omitempty"`
277+ CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
278+ // HMAC-SHA256 of the client key (32 bytes).
279+ IpHash []byte `protobuf:"bytes,5,opt,name=ip_hash,json=ipHash,proto3" json:"ip_hash,omitempty"`
280+ unknownFields protoimpl.UnknownFields
281+ sizeCache protoimpl.SizeCache
282+}
283+
284+func (x *PendingIssue) Reset() {
285+ *x = PendingIssue{}
286+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
287+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
288+ ms.StoreMessageInfo(mi)
289+}
290+
291+func (x *PendingIssue) String() string {
292+ return protoimpl.X.MessageStringOf(x)
293+}
294+
295+func (*PendingIssue) ProtoMessage() {}
296+
297+func (x *PendingIssue) ProtoReflect() protoreflect.Message {
298+ mi := &file_ermite_storage_issue_proto_msgTypes[2]
299+ if x != nil {
300+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
301+ if ms.LoadMessageInfo() == nil {
302+ ms.StoreMessageInfo(mi)
303+ }
304+ return ms
305+ }
306+ return mi.MessageOf(x)
307+}
308+
309+// Deprecated: Use PendingIssue.ProtoReflect.Descriptor instead.
310+func (*PendingIssue) Descriptor() ([]byte, []int) {
311+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{2}
312+}
313+
314+func (x *PendingIssue) GetTitle() string {
315+ if x != nil {
316+ return x.Title
317+ }
318+ return ""
319+}
320+
321+func (x *PendingIssue) GetBody() string {
322+ if x != nil {
323+ return x.Body
324+ }
325+ return ""
326+}
327+
328+func (x *PendingIssue) GetAuthor() string {
329+ if x != nil {
330+ return x.Author
331+ }
332+ return ""
333+}
334+
335+func (x *PendingIssue) GetCreatedAt() *timestamppb.Timestamp {
336+ if x != nil {
337+ return x.CreatedAt
338+ }
339+ return nil
340+}
341+
342+func (x *PendingIssue) GetIpHash() []byte {
343+ if x != nil {
344+ return x.IpHash
345+ }
346+ return nil
347+}
348+
349+// IssueBan is stored under the key "isban/<hex of the hash>".
350+type IssueBan struct {
351+ state protoimpl.MessageState `protogen:"open.v1"`
352+ Until *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=until,proto3" json:"until,omitempty"`
353+ unknownFields protoimpl.UnknownFields
354+ sizeCache protoimpl.SizeCache
355+}
356+
357+func (x *IssueBan) Reset() {
358+ *x = IssueBan{}
359+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
360+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
361+ ms.StoreMessageInfo(mi)
362+}
363+
364+func (x *IssueBan) String() string {
365+ return protoimpl.X.MessageStringOf(x)
366+}
367+
368+func (*IssueBan) ProtoMessage() {}
369+
370+func (x *IssueBan) ProtoReflect() protoreflect.Message {
371+ mi := &file_ermite_storage_issue_proto_msgTypes[3]
372+ if x != nil {
373+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
374+ if ms.LoadMessageInfo() == nil {
375+ ms.StoreMessageInfo(mi)
376+ }
377+ return ms
378+ }
379+ return mi.MessageOf(x)
380+}
381+
382+// Deprecated: Use IssueBan.ProtoReflect.Descriptor instead.
383+func (*IssueBan) Descriptor() ([]byte, []int) {
384+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{3}
385+}
386+
387+func (x *IssueBan) GetUntil() *timestamppb.Timestamp {
388+ if x != nil {
389+ return x.Until
390+ }
391+ return nil
392+}
393+
394+// IssueKey is stored under the key "meta/issuekey".
395+type IssueKey struct {
396+ state protoimpl.MessageState `protogen:"open.v1"`
397+ Key []byte `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
398+ unknownFields protoimpl.UnknownFields
399+ sizeCache protoimpl.SizeCache
400+}
401+
402+func (x *IssueKey) Reset() {
403+ *x = IssueKey{}
404+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
405+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
406+ ms.StoreMessageInfo(mi)
407+}
408+
409+func (x *IssueKey) String() string {
410+ return protoimpl.X.MessageStringOf(x)
411+}
412+
413+func (*IssueKey) ProtoMessage() {}
414+
415+func (x *IssueKey) ProtoReflect() protoreflect.Message {
416+ mi := &file_ermite_storage_issue_proto_msgTypes[4]
417+ if x != nil {
418+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
419+ if ms.LoadMessageInfo() == nil {
420+ ms.StoreMessageInfo(mi)
421+ }
422+ return ms
423+ }
424+ return mi.MessageOf(x)
425+}
426+
427+// Deprecated: Use IssueKey.ProtoReflect.Descriptor instead.
428+func (*IssueKey) Descriptor() ([]byte, []int) {
429+ return file_ermite_storage_issue_proto_rawDescGZIP(), []int{4}
430+}
431+
432+func (x *IssueKey) GetKey() []byte {
433+ if x != nil {
434+ return x.Key
435+ }
436+ return nil
437+}
438+
439+var File_ermite_storage_issue_proto protoreflect.FileDescriptor
440+
441+const file_ermite_storage_issue_proto_rawDesc = "" +
442+ "\n" +
443+ "\x1aermite/storage/issue.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc6\x02\n" +
444+ "\x05Issue\x12\x16\n" +
445+ "\x06number\x18\x01 \x01(\x03R\x06number\x12\x14\n" +
446+ "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" +
447+ "\x04body\x18\x03 \x01(\tR\x04body\x120\n" +
448+ "\x05state\x18\x04 \x01(\x0e2\x1a.ermite.storage.IssueStateR\x05state\x12\x16\n" +
449+ "\x06author\x18\x05 \x01(\tR\x06author\x129\n" +
450+ "\n" +
451+ "created_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" +
452+ "\n" +
453+ "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x1f\n" +
454+ "\vcomment_seq\x18\b \x01(\x03R\n" +
455+ "commentSeq\x12\x1a\n" +
456+ "\bexternal\x18\t \x01(\bR\bexternal\"\x85\x01\n" +
457+ "\fIssueComment\x12\x0e\n" +
458+ "\x02id\x18\x01 \x01(\x03R\x02id\x12\x16\n" +
459+ "\x06author\x18\x02 \x01(\tR\x06author\x12\x12\n" +
460+ "\x04text\x18\x03 \x01(\tR\x04text\x129\n" +
461+ "\n" +
462+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xa4\x01\n" +
463+ "\fPendingIssue\x12\x14\n" +
464+ "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" +
465+ "\x04body\x18\x02 \x01(\tR\x04body\x12\x16\n" +
466+ "\x06author\x18\x03 \x01(\tR\x06author\x129\n" +
467+ "\n" +
468+ "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x17\n" +
469+ "\aip_hash\x18\x05 \x01(\fR\x06ipHash\"<\n" +
470+ "\bIssueBan\x120\n" +
471+ "\x05until\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x05until\"\x1c\n" +
472+ "\bIssueKey\x12\x10\n" +
473+ "\x03key\x18\x01 \x01(\fR\x03key*p\n" +
474+ "\n" +
475+ "IssueState\x12\x1b\n" +
476+ "\x17ISSUE_STATE_UNSPECIFIED\x10\x00\x12\x14\n" +
477+ "\x10ISSUE_STATE_OPEN\x10\x01\x12\x16\n" +
478+ "\x12ISSUE_STATE_CLOSED\x10\x02\x12\x17\n" +
479+ "\x13ISSUE_STATE_PENDING\x10\x03B/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3"
480+
481+var (
482+ file_ermite_storage_issue_proto_rawDescOnce sync.Once
483+ file_ermite_storage_issue_proto_rawDescData []byte
484+)
485+
486+func file_ermite_storage_issue_proto_rawDescGZIP() []byte {
487+ file_ermite_storage_issue_proto_rawDescOnce.Do(func() {
488+ file_ermite_storage_issue_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)))
489+ })
490+ return file_ermite_storage_issue_proto_rawDescData
491+}
492+
493+var file_ermite_storage_issue_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
494+var file_ermite_storage_issue_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
495+var file_ermite_storage_issue_proto_goTypes = []any{
496+ (IssueState)(0), // 0: ermite.storage.IssueState
497+ (*Issue)(nil), // 1: ermite.storage.Issue
498+ (*IssueComment)(nil), // 2: ermite.storage.IssueComment
499+ (*PendingIssue)(nil), // 3: ermite.storage.PendingIssue
500+ (*IssueBan)(nil), // 4: ermite.storage.IssueBan
501+ (*IssueKey)(nil), // 5: ermite.storage.IssueKey
502+ (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp
503+}
504+var file_ermite_storage_issue_proto_depIdxs = []int32{
505+ 0, // 0: ermite.storage.Issue.state:type_name -> ermite.storage.IssueState
506+ 6, // 1: ermite.storage.Issue.created_at:type_name -> google.protobuf.Timestamp
507+ 6, // 2: ermite.storage.Issue.updated_at:type_name -> google.protobuf.Timestamp
508+ 6, // 3: ermite.storage.IssueComment.created_at:type_name -> google.protobuf.Timestamp
509+ 6, // 4: ermite.storage.PendingIssue.created_at:type_name -> google.protobuf.Timestamp
510+ 6, // 5: ermite.storage.IssueBan.until:type_name -> google.protobuf.Timestamp
511+ 6, // [6:6] is the sub-list for method output_type
512+ 6, // [6:6] is the sub-list for method input_type
513+ 6, // [6:6] is the sub-list for extension type_name
514+ 6, // [6:6] is the sub-list for extension extendee
515+ 0, // [0:6] is the sub-list for field type_name
516+}
517+
518+func init() { file_ermite_storage_issue_proto_init() }
519+func file_ermite_storage_issue_proto_init() {
520+ if File_ermite_storage_issue_proto != nil {
521+ return
522+ }
523+ type x struct{}
524+ out := protoimpl.TypeBuilder{
525+ File: protoimpl.DescBuilder{
526+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
527+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_issue_proto_rawDesc), len(file_ermite_storage_issue_proto_rawDesc)),
528+ NumEnums: 1,
529+ NumMessages: 5,
530+ NumExtensions: 0,
531+ NumServices: 0,
532+ },
533+ GoTypes: file_ermite_storage_issue_proto_goTypes,
534+ DependencyIndexes: file_ermite_storage_issue_proto_depIdxs,
535+ EnumInfos: file_ermite_storage_issue_proto_enumTypes,
536+ MessageInfos: file_ermite_storage_issue_proto_msgTypes,
537+ }.Build()
538+ File_ermite_storage_issue_proto = out.File
539+ file_ermite_storage_issue_proto_goTypes = nil
540+ file_ermite_storage_issue_proto_depIdxs = nil
541+}
M gen/ermite/storage/merge_request.pb.go +109 -38 generated file: collapsed
M go.mod +2 -2
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.8.0
16+ bnema.dev/ermite-api v0.9.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -32,6 +32,7 @@ require (
3232 golang.org/x/crypto v0.57.0
3333 golang.org/x/sys v0.49.0
3434 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
3536 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3637 google.golang.org/protobuf v1.36.12
3738 )
@@ -164,7 +165,6 @@ require (
164165 golang.org/x/mod v0.41.0 // indirect
165166 golang.org/x/net v0.59.0 // indirect
166167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168168 golang.org/x/tools v0.51.0 // indirect
169169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.8.016+ bnema.dev/ermite-api v0.9.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
@@ -32,6 +32,7 @@ require (
32 golang.org/x/crypto v0.57.032 golang.org/x/crypto v0.57.0
33 golang.org/x/sys v0.49.033 golang.org/x/sys v0.49.0
34 golang.org/x/term v0.46.034 golang.org/x/term v0.46.0
35+ golang.org/x/text v0.42.0
35 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a436 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
36 google.golang.org/protobuf v1.36.1237 google.golang.org/protobuf v1.36.12
37 )38 )
@@ -164,7 +165,6 @@ require (
164 golang.org/x/mod v0.41.0 // indirect165 golang.org/x/mod v0.41.0 // indirect
165 golang.org/x/net v0.59.0 // indirect166 golang.org/x/net v0.59.0 // indirect
166 golang.org/x/sync v0.23.0 // indirect167 golang.org/x/sync v0.23.0 // indirect
167- golang.org/x/text v0.42.0 // indirect
168 golang.org/x/tools v0.51.0 // indirect168 golang.org/x/tools v0.51.0 // indirect
169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect169 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect170 gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
@@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient {
9595 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
9696 }
9797
98+// Issues returns the IssueService client.
99+func (c *Client) Issues() ermiteconnect.IssueServiceClient {
100+ return ermiteconnect.NewIssueServiceClient(c.http, c.base, c.opts...)
101+}
102+
98103 // Secrets returns the SecretService client.
99104 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
100105 return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)
@@ -95,6 +95,11 @@ func (c *Client) MergeRequests() ermiteconnect.MergeRequestServiceClient {
95 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)95 return ermiteconnect.NewMergeRequestServiceClient(c.http, c.base, c.opts...)
96 }96 }
97 97
98+// Issues returns the IssueService client.
99+func (c *Client) Issues() ermiteconnect.IssueServiceClient {
100+ return ermiteconnect.NewIssueServiceClient(c.http, c.base, c.opts...)
101+}
102+
98 // Secrets returns the SecretService client.103 // Secrets returns the SecretService client.
99 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {104 func (c *Client) Secrets() ermiteconnect.SecretServiceClient {
100 return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)105 return ermiteconnect.NewSecretServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,
151151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
@@ -139,15 +139,15 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSession,
146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows,
147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,147 client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148 }))148 }))
149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,
151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,151 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIWorkflows, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152 }))152 }))
153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
M internal/adapters/client/features.go +4 -1
@@ -18,6 +18,9 @@ const (
1818 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
1919 // (`ermite mr merge --delete-branch`).
2020 FeatureMergeDeleteBranch = "merge_requests.delete_branch"
21+ // FeatureIssues is IssueService, RepoSettings.issues and the issue:write
22+ // scope (`ermite issue`, `ermite repo edit --issues`).
23+ FeatureIssues = "issues"
2124 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
2225 FeatureSigningKey = "signing_key"
2326 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -55,7 +58,7 @@ const (
5558
5659 // UsedFeatures lists the features the CLI uses, in the order they are reported.
5760 func UsedFeatures() []string {
58- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
61+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
5962 }
6063
6164 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -18,6 +18,9 @@ const (
18 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch18 // FeatureMergeDeleteBranch is MergeMergeRequest with delete_source_branch
19 // (`ermite mr merge --delete-branch`).19 // (`ermite mr merge --delete-branch`).
20 FeatureMergeDeleteBranch = "merge_requests.delete_branch"20 FeatureMergeDeleteBranch = "merge_requests.delete_branch"
21+ // FeatureIssues is IssueService, RepoSettings.issues and the issue:write
22+ // scope (`ermite issue`, `ermite repo edit --issues`).
23+ FeatureIssues = "issues"
21 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).24 // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`).
22 FeatureSigningKey = "signing_key"25 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).26 // FeatureSession is SessionService (`ermite web login`, `web logout`).
@@ -55,7 +58,7 @@ const (
55 58
56 // UsedFeatures lists the features the CLI uses, in the order they are reported.59 // UsedFeatures lists the features the CLI uses, in the order they are reported.
57 func UsedFeatures() []string {60 func UsedFeatures() []string {
58- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}61+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
59 }62 }
60 63
61 // MissingFeatures returns the features the CLI uses that have is lacking.64 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/client/scopes_test.go +2 -0
@@ -26,6 +26,7 @@ func TestScopeWireNames(t *testing.T) {
2626 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",
2727 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
2828 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
29+ ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
2930 }
3031 for s, name := range want {
3132 assert.Equal(t, name, client.ScopeName(s))
@@ -44,6 +45,7 @@ func TestPresetExpansion(t *testing.T) {
4445
4546 agent, err := client.PresetScopes("agent")
4647 require.NoError(t, err)
48+ assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked")
4749 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)
4850
4951 ci, err := client.PresetScopes("CI")
@@ -26,6 +26,7 @@ func TestScopeWireNames(t *testing.T) {
26 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",26 ermite.Scope_SCOPE_SECRETS_WRITE: "secrets:write",
27 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",27 ermite.Scope_SCOPE_RUNNER_REGISTER: "runner:register",
28 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",28 ermite.Scope_SCOPE_TOKEN_ADMIN: "token:admin",
29+ ermite.Scope_SCOPE_ISSUE_WRITE: "issue:write",
29 }30 }
30 for s, name := range want {31 for s, name := range want {
31 assert.Equal(t, name, client.ScopeName(s))32 assert.Equal(t, name, client.ScopeName(s))
@@ -44,6 +45,7 @@ func TestPresetExpansion(t *testing.T) {
44 45
45 agent, err := client.PresetScopes("agent")46 agent, err := client.PresetScopes("agent")
46 require.NoError(t, err)47 require.NoError(t, err)
48+ assert.NotContains(t, agent, ermite.Scope_SCOPE_ISSUE_WRITE, "agents get issue:write only when asked")
47 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)49 assert.Equal(t, []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, agent)
48 50
49 ci, err := client.PresetScopes("CI")51 ci, err := client.PresetScopes("CI")
M internal/adapters/kvstore/export_test.go +41 -1
@@ -2,7 +2,13 @@
22
33 package kvstore
44
5-import "context"
5+import (
6+ "context"
7+
8+ kv "bnema.dev/ermite-kv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
612
713 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of
814 // DeleteEnvironment and its batch, and returns the function that removes it.
@@ -25,3 +31,37 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
2531 item, ok := c.db.Get(ctx, runnerKey(id))
2632 return item.Version, ok
2733 }
34+
35+// CountKeysForTest counts the keys of the database under prefix.
36+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
37+ n := 0
38+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
39+ n++
40+ }
41+ return n
42+}
43+
44+// DropIssueRecordForTest deletes the issue record and leaves its open index
45+// entry: the state of an index that is stale.
46+func (i *Issues) DropIssueRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
47+ var b kv.Batch
48+ b.Delete(issueKey(repo, n))
49+ _, err := i.db.Apply(ctx, &b)
50+ return err
51+}
52+
53+// DropMRRecordForTest is DropIssueRecordForTest for a merge request.
54+func (m *MergeRequests) DropMRRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
55+ var b kv.Batch
56+ b.Delete(mrKey(repo, n))
57+ _, err := m.db.Apply(ctx, &b)
58+ return err
59+}
60+
61+// PutRawIssueForTest stores value as the issue record, bypassing the codec.
62+func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n int64, value []byte) error {
63+ var b kv.Batch
64+ b.Put(issueKey(repo, n), value)
65+ _, err := i.db.Apply(ctx, &b)
66+ return err
67+}
@@ -2,7 +2,13 @@
2 2
3 package kvstore3 package kvstore
4 4
5-import "context"5+import (
6+ "context"
7+
8+ kv "bnema.dev/ermite-kv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
6 12
7 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of13 // SetAfterSettingsSnapshot installs the hook that runs between the snapshot of
8 // DeleteEnvironment and its batch, and returns the function that removes it.14 // DeleteEnvironment and its batch, and returns the function that removes it.
@@ -25,3 +31,37 @@ func (c *CI) RawVersionForTest(ctx context.Context, id string) (uint64, bool) {
25 item, ok := c.db.Get(ctx, runnerKey(id))31 item, ok := c.db.Get(ctx, runnerKey(id))
26 return item.Version, ok32 return item.Version, ok
27 }33 }
34+
35+// CountKeysForTest counts the keys of the database under prefix.
36+func (s *Store) CountKeysForTest(ctx context.Context, prefix string) int {
37+ n := 0
38+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
39+ n++
40+ }
41+ return n
42+}
43+
44+// DropIssueRecordForTest deletes the issue record and leaves its open index
45+// entry: the state of an index that is stale.
46+func (i *Issues) DropIssueRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
47+ var b kv.Batch
48+ b.Delete(issueKey(repo, n))
49+ _, err := i.db.Apply(ctx, &b)
50+ return err
51+}
52+
53+// DropMRRecordForTest is DropIssueRecordForTest for a merge request.
54+func (m *MergeRequests) DropMRRecordForTest(ctx context.Context, repo domain.RepoName, n int64) error {
55+ var b kv.Batch
56+ b.Delete(mrKey(repo, n))
57+ _, err := m.db.Apply(ctx, &b)
58+ return err
59+}
60+
61+// PutRawIssueForTest stores value as the issue record, bypassing the codec.
62+func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n int64, value []byte) error {
63+ var b kv.Batch
64+ b.Put(issueKey(repo, n), value)
65+ _, err := i.db.Apply(ctx, &b)
66+ return err
67+}
A internal/adapters/kvstore/issues.go +437 -0
@@ -0,0 +1,437 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+ locks *numberLocks
36+}
37+
38+var _ ports.IssueStore = (*Issues)(nil)
39+
40+// Issues returns the issue store over the same database. It is valid until the
41+// Store is closed.
42+func (s *Store) Issues() *Issues { return &Issues{db: s.db, locks: s.locks} }
43+
44+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
45+func issueKey(repo domain.RepoName, n int64) []byte {
46+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
47+}
48+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
49+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
50+}
51+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
52+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
53+}
54+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
55+func issueOpenKey(repo domain.RepoName, n int64) []byte {
56+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
57+}
58+
59+// indexOpenIssue adds to b the write that keeps the index of the open issues in
60+// step with the issue: the key is put when it is open, deleted otherwise
61+// (deleting a key that is not there is not an error).
62+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
63+ if issue.IsOpen() {
64+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
65+ return
66+ }
67+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
68+}
69+
70+// issueKeys lists every key of the snapshot that belongs to the repository's
71+// issues.
72+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73+ var keys [][]byte
74+ for _, p := range []string{
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
76+ } {
77+ for k := range snap.Prefix(ctx, []byte(p)) {
78+ keys = append(keys, slices.Clone(k))
79+ }
80+ }
81+ return keys
82+}
83+
84+// Create takes the next number of the repository from mrseq/<repo>, the
85+// sequence of the merge requests, and stores the issue under it; see
86+// createNumbered. A missing repository gives domain.ErrNotFound.
87+//
88+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
89+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
90+ err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
91+ issue.Number = n
92+ val, err := encodeIssue(issue)
93+ if err != nil {
94+ return err
95+ }
96+ b.RequireAbsent(issueKey(issue.Repo, n))
97+ b.Put(issueKey(issue.Repo, n), val)
98+ indexOpenIssue(b, issue)
99+ return nil
100+ })
101+ if err != nil {
102+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
103+ }
104+ return i.Get(ctx, issue.Repo, issue.Number)
105+}
106+
107+// Get reads is/<repo>/<number>.
108+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
109+ if number < 1 {
110+ return domain.Issue{}, domain.ErrNotFound
111+ }
112+ item, ok := i.db.Get(ctx, issueKey(repo, number))
113+ if !ok {
114+ return domain.Issue{}, domain.ErrNotFound
115+ }
116+ return decodeIssue(repo, item)
117+}
118+
119+// List returns a page of the issues of the repository, highest number first. The
120+// page token is the last number of the previous page.
121+//
122+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
123+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
124+ before := int64(0) // 0: from the highest
125+ if params.PageToken != "" {
126+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
127+ if err != nil || n < 1 {
128+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
129+ }
130+ before = n
131+ }
132+ // The keys sort by number: walk [first, cursor) ascending, keep the last
133+ // PageSize+1 matching records undecoded (the extra one tells whether
134+ // another page follows), then decode only those.
135+ start := []byte(issueRepoPrefix(repo))
136+ end := append(slices.Clone(start[:len(start)-1]), '/'+1) // the prefix ends in '/'
137+ if before > 0 {
138+ end = issueKey(repo, before)
139+ }
140+ keep := params.PageSize + 1
141+ var window []kv.Item
142+ for _, item := range i.db.Snapshot(ctx).Range(ctx, start, end) {
143+ if params.State != "" {
144+ var pb storagepb.Issue
145+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
146+ return ports.IssuePage{}, fmt.Errorf("decode issue: %w", err)
147+ }
148+ st, err := decodeIssueState(pb.GetState())
149+ if err != nil {
150+ return ports.IssuePage{}, fmt.Errorf("decode issue of %q: %w", repo, err)
151+ }
152+ if st != params.State {
153+ continue
154+ }
155+ }
156+ window = append(window, item)
157+ if params.PageSize > 0 && len(window) >= 2*keep {
158+ window = append(window[:0], window[len(window)-keep:]...)
159+ }
160+ }
161+ if params.PageSize > 0 && len(window) > keep {
162+ window = window[len(window)-keep:]
163+ }
164+ all := make([]domain.Issue, 0, len(window))
165+ for _, item := range slices.Backward(window) {
166+ issue, err := decodeIssue(repo, item)
167+ if err != nil {
168+ return ports.IssuePage{}, err
169+ }
170+ all = append(all, issue)
171+ }
172+ var page ports.IssuePage
173+ if params.PageSize > 0 && len(all) > params.PageSize {
174+ all = all[:params.PageSize]
175+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
176+ }
177+ page.Issues = all
178+ return page, nil
179+}
180+
181+// ListOpenAll returns the open issues of every repository through the isopen/
182+// index: the issues it names are read, no other.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues of all repositories through their index: a storage concern, no policy
185+func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
186+ type ref struct {
187+ repo domain.RepoName
188+ n int64
189+ }
190+ var refs []ref
191+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenPrefix)) {
192+ if err := ctx.Err(); err != nil {
193+ return nil, err
194+ }
195+ repoName, num, ok := strings.Cut(strings.TrimPrefix(string(key), issueOpenPrefix), "/")
196+ if !ok {
197+ return nil, fmt.Errorf("index key %q", key)
198+ }
199+ repo, err := domain.ParseRepoName(repoName)
200+ if err != nil {
201+ return nil, fmt.Errorf("index key %q: %w", key, err)
202+ }
203+ n, err := strconv.ParseInt(num, 10, 64)
204+ if err != nil {
205+ return nil, fmt.Errorf("index key %q: %w", key, err)
206+ }
207+ refs = append(refs, ref{repo, n})
208+ }
209+ var out []domain.Issue
210+ for _, r := range refs {
211+ issue, err := i.Get(ctx, r.repo, r.n)
212+ if errors.Is(err, domain.ErrNotFound) {
213+ continue // closed or deleted since the snapshot of the index
214+ }
215+ if err != nil {
216+ return nil, err
217+ }
218+ if issue.IsOpen() {
219+ out = append(out, issue)
220+ }
221+ }
222+ return out, nil
223+}
224+
225+// Update replaces the issue with a version precondition and moves its index key
226+// in the same batch. The repository must exist (see guardRepoKey), so that a
227+// concurrent delete of the repository leaves no index key behind.
228+//
229+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
230+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
231+ val, err := encodeIssue(issue)
232+ if err != nil {
233+ return err
234+ }
235+ key := issueKey(issue.Repo, issue.Number)
236+ if _, ok := i.db.Get(ctx, key); !ok {
237+ return domain.ErrNotFound
238+ }
239+ if issue.Version == 0 {
240+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
241+ }
242+ var b kv.Batch
243+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
244+ return err
245+ }
246+ b.RequireVersion(key, issue.Version)
247+ b.Put(key, val)
248+ indexOpenIssue(&b, issue)
249+ if _, err := i.db.Apply(ctx, &b); err != nil {
250+ if errors.Is(err, kv.ErrConflict) {
251+ return domain.ErrConflict
252+ }
253+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
254+ }
255+ return nil
256+}
257+
258+// ListComments returns the comments of the issue, oldest first.
259+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
260+ var out []domain.IssueComment
261+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
262+ c, err := decodeIssueComment(item)
263+ if err != nil {
264+ return nil, fmt.Errorf("decode %s: %w", key, err)
265+ }
266+ out = append(out, c)
267+ }
268+ return out, nil
269+}
270+
271+// SaveComment writes the comment and the issue in one batch, each with its
272+// version precondition (absent for a new comment), and requires the repository
273+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
274+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
275+ issueVal, err := encodeIssue(issue)
276+ if err != nil {
277+ return err
278+ }
279+ cVal, err := encodeIssueComment(c)
280+ if err != nil {
281+ return err
282+ }
283+ if issue.Version == 0 {
284+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
285+ }
286+ var b kv.Batch
287+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
288+ return err
289+ }
290+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
291+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
292+ indexOpenIssue(&b, issue)
293+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
294+ if c.Version == 0 {
295+ b.RequireAbsent(ckey)
296+ } else {
297+ b.RequireVersion(ckey, c.Version)
298+ }
299+ b.Put(ckey, cVal)
300+ if _, err := i.db.Apply(ctx, &b); err != nil {
301+ if errors.Is(err, kv.ErrConflict) {
302+ return domain.ErrConflict
303+ }
304+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
305+ }
306+ return nil
307+}
308+
309+func encodeIssue(issue domain.Issue) ([]byte, error) {
310+ if issue.Repo.IsZero() {
311+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
312+ }
313+ if !utf8.ValidString(issue.Author) {
314+ return nil, errors.New("issue author is not valid UTF-8")
315+ }
316+ state, err := encodeIssueState(issue.State)
317+ if err != nil {
318+ return nil, err
319+ }
320+ return mustMarshal(&storagepb.Issue{
321+ Number: issue.Number,
322+ Title: issue.Title.String(),
323+ Body: issue.Body.String(),
324+ State: state,
325+ Author: issue.Author,
326+ CreatedAt: encodeTime(issue.CreatedAt),
327+ UpdatedAt: encodeTime(issue.UpdatedAt),
328+ CommentSeq: issue.CommentSeq,
329+ External: issue.External,
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
335+ var pb storagepb.Issue
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
338+ }
339+ fail := func(err error) (domain.Issue, error) {
340+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
341+ }
342+ title, err := domain.ParseIssueTitle(pb.GetTitle())
343+ if err != nil {
344+ return fail(err)
345+ }
346+ body, err := domain.ParseIssueBody(pb.GetBody())
347+ if err != nil {
348+ return fail(err)
349+ }
350+ state, err := decodeIssueState(pb.GetState())
351+ if err != nil {
352+ return fail(err)
353+ }
354+ return domain.Issue{
355+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
356+ Author: pb.GetAuthor(),
357+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
358+ CommentSeq: pb.GetCommentSeq(),
359+ External: pb.GetExternal(),
360+ Version: item.Version,
361+ }, nil
362+}
363+
364+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
365+ if !utf8.ValidString(c.Author) {
366+ return nil, errors.New("issue comment author is not valid UTF-8")
367+ }
368+ return mustMarshal(&storagepb.IssueComment{
369+ Id: c.ID,
370+ Author: c.Author,
371+ Text: c.Text.String(),
372+ CreatedAt: encodeTime(c.CreatedAt),
373+ }), nil
374+}
375+
376+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
377+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
378+ var pb storagepb.IssueComment
379+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
380+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
381+ }
382+ text, err := domain.ParseIssueBody(pb.GetText())
383+ if err != nil {
384+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
385+ }
386+ return domain.IssueComment{
387+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
388+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
389+ }, nil
390+}
391+
392+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
393+ switch s {
394+ case domain.IssueStateOpen:
395+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
396+ case domain.IssueStateClosed:
397+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
398+ }
399+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
400+}
401+
402+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
403+ switch s {
404+ case storagepb.IssueState_ISSUE_STATE_OPEN:
405+ return domain.IssueStateOpen, nil
406+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
407+ return domain.IssueStateClosed, nil
408+ case storagepb.IssueState_ISSUE_STATE_PENDING:
409+ return "", errors.New("stored issue is pending: a numbered issue is open or closed")
410+ }
411+ return "", fmt.Errorf("unknown issue state %d", s)
412+}
413+
414+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
415+ switch m {
416+ case "":
417+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
418+ case domain.IssuesOff:
419+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
420+ case domain.IssuesOwner:
421+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
422+ case domain.IssuesPublic:
423+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
424+ }
425+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
426+}
427+
428+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
429+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
430+ switch m {
431+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
432+ return domain.IssuesOwner
433+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
434+ return domain.IssuesPublic
435+ }
436+ return domain.IssuesOff
437+}
@@ -0,0 +1,437 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "unicode/utf8"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of the issues, like that of the merge requests. The numbers come
23+// from the sequence of the merge requests (mrseq/<repo>): an issue and a merge
24+// request never share one.
25+const (
26+ issuePrefix = "is/" // is/<repo>/<number:010d>
27+ issueCommentPrefix = "isc/" // isc/<repo>/<number:010d>/<id:010d>
28+ issueOpenPrefix = "isopen/" // isopen/<repo>/<number:010d>, written with the issue
29+)
30+
31+// Issues is the ermite-kv backed ports.IssueStore. It shares the database of
32+// the Store it comes from.
33+type Issues struct {
34+ db *kv.DB
35+ locks *numberLocks
36+}
37+
38+var _ ports.IssueStore = (*Issues)(nil)
39+
40+// Issues returns the issue store over the same database. It is valid until the
41+// Store is closed.
42+func (s *Store) Issues() *Issues { return &Issues{db: s.db, locks: s.locks} }
43+
44+func issueRepoPrefix(repo domain.RepoName) string { return issuePrefix + repo.String() + "/" }
45+func issueKey(repo domain.RepoName, n int64) []byte {
46+ return []byte(issueRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
47+}
48+func issueCommentsPrefix(repo domain.RepoName, n int64) string {
49+ return issueCommentPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n) + "/"
50+}
51+func issueCommentKey(repo domain.RepoName, n, id int64) []byte {
52+ return []byte(issueCommentsPrefix(repo, n) + fmt.Sprintf(numberFormat, id))
53+}
54+func issueOpenRepoPrefix(repo domain.RepoName) string { return issueOpenPrefix + repo.String() + "/" }
55+func issueOpenKey(repo domain.RepoName, n int64) []byte {
56+ return []byte(issueOpenRepoPrefix(repo) + fmt.Sprintf(numberFormat, n))
57+}
58+
59+// indexOpenIssue adds to b the write that keeps the index of the open issues in
60+// step with the issue: the key is put when it is open, deleted otherwise
61+// (deleting a key that is not there is not an error).
62+func indexOpenIssue(b *kv.Batch, issue domain.Issue) {
63+ if issue.IsOpen() {
64+ b.Put(issueOpenKey(issue.Repo, issue.Number), []byte{1})
65+ return
66+ }
67+ b.Delete(issueOpenKey(issue.Repo, issue.Number))
68+}
69+
70+// issueKeys lists every key of the snapshot that belongs to the repository's
71+// issues.
72+func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
73+ var keys [][]byte
74+ for _, p := range []string{
75+ issueRepoPrefix(repo), issueCommentPrefix + repo.String() + "/", issueOpenRepoPrefix(repo),
76+ } {
77+ for k := range snap.Prefix(ctx, []byte(p)) {
78+ keys = append(keys, slices.Clone(k))
79+ }
80+ }
81+ return keys
82+}
83+
84+// Create takes the next number of the repository from mrseq/<repo>, the
85+// sequence of the merge requests, and stores the issue under it; see
86+// createNumbered. A missing repository gives domain.ErrNotFound.
87+//
88+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
89+func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) {
90+ err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error {
91+ issue.Number = n
92+ val, err := encodeIssue(issue)
93+ if err != nil {
94+ return err
95+ }
96+ b.RequireAbsent(issueKey(issue.Repo, n))
97+ b.Put(issueKey(issue.Repo, n), val)
98+ indexOpenIssue(b, issue)
99+ return nil
100+ })
101+ if err != nil {
102+ return domain.Issue{}, fmt.Errorf("create issue of %q: %w", issue.Repo, err)
103+ }
104+ return i.Get(ctx, issue.Repo, issue.Number)
105+}
106+
107+// Get reads is/<repo>/<number>.
108+func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.Issue, error) {
109+ if number < 1 {
110+ return domain.Issue{}, domain.ErrNotFound
111+ }
112+ item, ok := i.db.Get(ctx, issueKey(repo, number))
113+ if !ok {
114+ return domain.Issue{}, domain.ErrNotFound
115+ }
116+ return decodeIssue(repo, item)
117+}
118+
119+// List returns a page of the issues of the repository, highest number first. The
120+// page token is the last number of the previous page.
121+//
122+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
123+func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) {
124+ before := int64(0) // 0: from the highest
125+ if params.PageToken != "" {
126+ n, err := strconv.ParseInt(params.PageToken, 10, 64)
127+ if err != nil || n < 1 {
128+ return ports.IssuePage{}, fmt.Errorf("%w: page token", domain.ErrInvalid)
129+ }
130+ before = n
131+ }
132+ // The keys sort by number: walk [first, cursor) ascending, keep the last
133+ // PageSize+1 matching records undecoded (the extra one tells whether
134+ // another page follows), then decode only those.
135+ start := []byte(issueRepoPrefix(repo))
136+ end := append(slices.Clone(start[:len(start)-1]), '/'+1) // the prefix ends in '/'
137+ if before > 0 {
138+ end = issueKey(repo, before)
139+ }
140+ keep := params.PageSize + 1
141+ var window []kv.Item
142+ for _, item := range i.db.Snapshot(ctx).Range(ctx, start, end) {
143+ if params.State != "" {
144+ var pb storagepb.Issue
145+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
146+ return ports.IssuePage{}, fmt.Errorf("decode issue: %w", err)
147+ }
148+ st, err := decodeIssueState(pb.GetState())
149+ if err != nil {
150+ return ports.IssuePage{}, fmt.Errorf("decode issue of %q: %w", repo, err)
151+ }
152+ if st != params.State {
153+ continue
154+ }
155+ }
156+ window = append(window, item)
157+ if params.PageSize > 0 && len(window) >= 2*keep {
158+ window = append(window[:0], window[len(window)-keep:]...)
159+ }
160+ }
161+ if params.PageSize > 0 && len(window) > keep {
162+ window = window[len(window)-keep:]
163+ }
164+ all := make([]domain.Issue, 0, len(window))
165+ for _, item := range slices.Backward(window) {
166+ issue, err := decodeIssue(repo, item)
167+ if err != nil {
168+ return ports.IssuePage{}, err
169+ }
170+ all = append(all, issue)
171+ }
172+ var page ports.IssuePage
173+ if params.PageSize > 0 && len(all) > params.PageSize {
174+ all = all[:params.PageSize]
175+ page.NextPageToken = strconv.FormatInt(all[len(all)-1].Number, 10)
176+ }
177+ page.Issues = all
178+ return page, nil
179+}
180+
181+// ListOpenAll returns the open issues of every repository through the isopen/
182+// index: the issues it names are read, no other.
183+//
184+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open issues of all repositories through their index: a storage concern, no policy
185+func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) {
186+ type ref struct {
187+ repo domain.RepoName
188+ n int64
189+ }
190+ var refs []ref
191+ for key := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueOpenPrefix)) {
192+ if err := ctx.Err(); err != nil {
193+ return nil, err
194+ }
195+ repoName, num, ok := strings.Cut(strings.TrimPrefix(string(key), issueOpenPrefix), "/")
196+ if !ok {
197+ return nil, fmt.Errorf("index key %q", key)
198+ }
199+ repo, err := domain.ParseRepoName(repoName)
200+ if err != nil {
201+ return nil, fmt.Errorf("index key %q: %w", key, err)
202+ }
203+ n, err := strconv.ParseInt(num, 10, 64)
204+ if err != nil {
205+ return nil, fmt.Errorf("index key %q: %w", key, err)
206+ }
207+ refs = append(refs, ref{repo, n})
208+ }
209+ var out []domain.Issue
210+ for _, r := range refs {
211+ issue, err := i.Get(ctx, r.repo, r.n)
212+ if errors.Is(err, domain.ErrNotFound) {
213+ continue // closed or deleted since the snapshot of the index
214+ }
215+ if err != nil {
216+ return nil, err
217+ }
218+ if issue.IsOpen() {
219+ out = append(out, issue)
220+ }
221+ }
222+ return out, nil
223+}
224+
225+// Update replaces the issue with a version precondition and moves its index key
226+// in the same batch. The repository must exist (see guardRepoKey), so that a
227+// concurrent delete of the repository leaves no index key behind.
228+//
229+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only
230+func (i *Issues) Update(ctx context.Context, issue domain.Issue) error {
231+ val, err := encodeIssue(issue)
232+ if err != nil {
233+ return err
234+ }
235+ key := issueKey(issue.Repo, issue.Number)
236+ if _, ok := i.db.Get(ctx, key); !ok {
237+ return domain.ErrNotFound
238+ }
239+ if issue.Version == 0 {
240+ return fmt.Errorf("update issue %d of %q: read it first: %w", issue.Number, issue.Repo, domain.ErrConflict)
241+ }
242+ var b kv.Batch
243+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
244+ return err
245+ }
246+ b.RequireVersion(key, issue.Version)
247+ b.Put(key, val)
248+ indexOpenIssue(&b, issue)
249+ if _, err := i.db.Apply(ctx, &b); err != nil {
250+ if errors.Is(err, kv.ErrConflict) {
251+ return domain.ErrConflict
252+ }
253+ return fmt.Errorf("update issue %d of %q: %w", issue.Number, issue.Repo, err)
254+ }
255+ return nil
256+}
257+
258+// ListComments returns the comments of the issue, oldest first.
259+func (i *Issues) ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.IssueComment, error) {
260+ var out []domain.IssueComment
261+ for key, item := range i.db.Snapshot(ctx).Prefix(ctx, []byte(issueCommentsPrefix(repo, number))) {
262+ c, err := decodeIssueComment(item)
263+ if err != nil {
264+ return nil, fmt.Errorf("decode %s: %w", key, err)
265+ }
266+ out = append(out, c)
267+ }
268+ return out, nil
269+}
270+
271+// SaveComment writes the comment and the issue in one batch, each with its
272+// version precondition (absent for a new comment), and requires the repository
273+// to exist (see guardRepoKey): domain.ErrNotFound otherwise.
274+func (i *Issues) SaveComment(ctx context.Context, issue domain.Issue, c domain.IssueComment) error {
275+ issueVal, err := encodeIssue(issue)
276+ if err != nil {
277+ return err
278+ }
279+ cVal, err := encodeIssueComment(c)
280+ if err != nil {
281+ return err
282+ }
283+ if issue.Version == 0 {
284+ return fmt.Errorf("save comment on %d of %q: read the issue first: %w", issue.Number, issue.Repo, domain.ErrConflict)
285+ }
286+ var b kv.Batch
287+ if err := guardRepoKey(ctx, i.db, &b, issue.Repo); err != nil {
288+ return err
289+ }
290+ b.RequireVersion(issueKey(issue.Repo, issue.Number), issue.Version)
291+ b.Put(issueKey(issue.Repo, issue.Number), issueVal)
292+ indexOpenIssue(&b, issue)
293+ ckey := issueCommentKey(issue.Repo, issue.Number, c.ID)
294+ if c.Version == 0 {
295+ b.RequireAbsent(ckey)
296+ } else {
297+ b.RequireVersion(ckey, c.Version)
298+ }
299+ b.Put(ckey, cVal)
300+ if _, err := i.db.Apply(ctx, &b); err != nil {
301+ if errors.Is(err, kv.ErrConflict) {
302+ return domain.ErrConflict
303+ }
304+ return fmt.Errorf("save comment on %d of %q: %w", issue.Number, issue.Repo, err)
305+ }
306+ return nil
307+}
308+
309+func encodeIssue(issue domain.Issue) ([]byte, error) {
310+ if issue.Repo.IsZero() {
311+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
312+ }
313+ if !utf8.ValidString(issue.Author) {
314+ return nil, errors.New("issue author is not valid UTF-8")
315+ }
316+ state, err := encodeIssueState(issue.State)
317+ if err != nil {
318+ return nil, err
319+ }
320+ return mustMarshal(&storagepb.Issue{
321+ Number: issue.Number,
322+ Title: issue.Title.String(),
323+ Body: issue.Body.String(),
324+ State: state,
325+ Author: issue.Author,
326+ CreatedAt: encodeTime(issue.CreatedAt),
327+ UpdatedAt: encodeTime(issue.UpdatedAt),
328+ CommentSeq: issue.CommentSeq,
329+ External: issue.External,
330+ }), nil
331+}
332+
333+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
334+func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) {
335+ var pb storagepb.Issue
336+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
337+ return domain.Issue{}, fmt.Errorf("decode issue: %w", err)
338+ }
339+ fail := func(err error) (domain.Issue, error) {
340+ return domain.Issue{}, fmt.Errorf("decode issue %d of %q: %w", pb.GetNumber(), repo, err)
341+ }
342+ title, err := domain.ParseIssueTitle(pb.GetTitle())
343+ if err != nil {
344+ return fail(err)
345+ }
346+ body, err := domain.ParseIssueBody(pb.GetBody())
347+ if err != nil {
348+ return fail(err)
349+ }
350+ state, err := decodeIssueState(pb.GetState())
351+ if err != nil {
352+ return fail(err)
353+ }
354+ return domain.Issue{
355+ Repo: repo, Number: pb.GetNumber(), Title: title, Body: body, State: state,
356+ Author: pb.GetAuthor(),
357+ CreatedAt: decodeTime(pb.GetCreatedAt()), UpdatedAt: decodeTime(pb.GetUpdatedAt()),
358+ CommentSeq: pb.GetCommentSeq(),
359+ External: pb.GetExternal(),
360+ Version: item.Version,
361+ }, nil
362+}
363+
364+func encodeIssueComment(c domain.IssueComment) ([]byte, error) {
365+ if !utf8.ValidString(c.Author) {
366+ return nil, errors.New("issue comment author is not valid UTF-8")
367+ }
368+ return mustMarshal(&storagepb.IssueComment{
369+ Id: c.ID,
370+ Author: c.Author,
371+ Text: c.Text.String(),
372+ CreatedAt: encodeTime(c.CreatedAt),
373+ }), nil
374+}
375+
376+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
377+func decodeIssueComment(item kv.Item) (domain.IssueComment, error) {
378+ var pb storagepb.IssueComment
379+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
380+ return domain.IssueComment{}, fmt.Errorf("decode issue comment: %w", err)
381+ }
382+ text, err := domain.ParseIssueBody(pb.GetText())
383+ if err != nil {
384+ return domain.IssueComment{}, fmt.Errorf("decode issue comment %d: %w", pb.GetId(), err)
385+ }
386+ return domain.IssueComment{
387+ ID: pb.GetId(), Author: pb.GetAuthor(), Text: text,
388+ CreatedAt: decodeTime(pb.GetCreatedAt()), Version: item.Version,
389+ }, nil
390+}
391+
392+func encodeIssueState(s domain.IssueState) (storagepb.IssueState, error) {
393+ switch s {
394+ case domain.IssueStateOpen:
395+ return storagepb.IssueState_ISSUE_STATE_OPEN, nil
396+ case domain.IssueStateClosed:
397+ return storagepb.IssueState_ISSUE_STATE_CLOSED, nil
398+ }
399+ return 0, fmt.Errorf("%w: issue state %q", domain.ErrInvalid, s)
400+}
401+
402+func decodeIssueState(s storagepb.IssueState) (domain.IssueState, error) {
403+ switch s {
404+ case storagepb.IssueState_ISSUE_STATE_OPEN:
405+ return domain.IssueStateOpen, nil
406+ case storagepb.IssueState_ISSUE_STATE_CLOSED:
407+ return domain.IssueStateClosed, nil
408+ case storagepb.IssueState_ISSUE_STATE_PENDING:
409+ return "", errors.New("stored issue is pending: a numbered issue is open or closed")
410+ }
411+ return "", fmt.Errorf("unknown issue state %d", s)
412+}
413+
414+func encodeIssuesMode(m domain.IssuesMode) (storagepb.IssuesMode, error) {
415+ switch m {
416+ case "":
417+ return storagepb.IssuesMode_ISSUES_MODE_UNSPECIFIED, nil
418+ case domain.IssuesOff:
419+ return storagepb.IssuesMode_ISSUES_MODE_OFF, nil
420+ case domain.IssuesOwner:
421+ return storagepb.IssuesMode_ISSUES_MODE_OWNER, nil
422+ case domain.IssuesPublic:
423+ return storagepb.IssuesMode_ISSUES_MODE_PUBLIC, nil
424+ }
425+ return 0, fmt.Errorf("%w: issues mode %q in the settings", domain.ErrInvalid, m)
426+}
427+
428+// decodeIssuesMode reads an unspecified or unknown mode as off: the safe side.
429+func decodeIssuesMode(m storagepb.IssuesMode) domain.IssuesMode {
430+ switch m {
431+ case storagepb.IssuesMode_ISSUES_MODE_OWNER:
432+ return domain.IssuesOwner
433+ case storagepb.IssuesMode_ISSUES_MODE_PUBLIC:
434+ return domain.IssuesPublic
435+ }
436+ return domain.IssuesOff
437+}
A internal/adapters/kvstore/issues_pending.go +408 -0
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+ "unicode/utf8"
16+
17+ kv "bnema.dev/ermite-kv"
18+ "github.com/bnema/zerowrap"
19+ "google.golang.org/protobuf/proto"
20+
21+ storagepb "bnema.dev/ermite/gen/ermite/storage"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// Key layout of the public issue form.
27+const (
28+ pendingPrefix = "ispend/" // ispend/<repo>/<id>
29+ banPrefix = "isban/" // isban/<hex of the hash>
30+ issueKeyKey = "meta/issuekey"
31+ issueKeyLen = 32
32+)
33+
34+// PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
35+// database of the Store it comes from.
36+type PendingIssues struct {
37+ db *kv.DB
38+ locks *numberLocks
39+}
40+
41+var _ ports.PendingIssueStore = (*PendingIssues)(nil)
42+
43+// PendingIssues returns the pending issue store over the same database. It is
44+// valid until the Store is closed.
45+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db, locks: s.locks} }
46+
47+func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
48+func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
49+ return []byte(pendingRepoPrefix(repo) + id.String())
50+}
51+
52+// pendingKeys lists every key of the snapshot that holds a pending issue of
53+// the repository.
54+func pendingKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
55+ var keys [][]byte
56+ for k := range snap.Prefix(ctx, []byte(pendingRepoPrefix(repo))) {
57+ keys = append(keys, slices.Clone(k))
58+ }
59+ return keys
60+}
61+
62+// Put stores the pending issue under a new key. The repository must exist (see
63+// guardRepoKey): domain.ErrNotFound otherwise.
64+func (s *PendingIssues) Put(ctx context.Context, p domain.PendingIssue) error {
65+ val, err := encodePending(p)
66+ if err != nil {
67+ return err
68+ }
69+ var b kv.Batch
70+ if err := guardRepoKey(ctx, s.db, &b, p.Repo); err != nil {
71+ return err
72+ }
73+ b.RequireAbsent(pendingKey(p.Repo, p.ID))
74+ b.Put(pendingKey(p.Repo, p.ID), val)
75+ if _, err := s.db.Apply(ctx, &b); err != nil {
76+ if errors.Is(err, kv.ErrConflict) {
77+ return domain.ErrConflict
78+ }
79+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
80+ }
81+ return nil
82+}
83+
84+// Get reads ispend/<repo>/<id>.
85+func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
86+ item, ok := s.db.Get(ctx, pendingKey(repo, id))
87+ if !ok {
88+ return domain.PendingIssue{}, domain.ErrNotFound
89+ }
90+ return decodePending(repo, id, item)
91+}
92+
93+// List reads the pending issues of the repository, or of all, sorted by
94+// creation time then repository then id. There are at most
95+// domain.MaxPendingIssues of them (a little more under a race), so they are
96+// read and sorted. The page token is the position of the last item returned.
97+//
98+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
99+func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
100+ prefix := pendingPrefix
101+ if !repo.IsZero() {
102+ prefix = pendingRepoPrefix(repo)
103+ }
104+ var after pendingCursor
105+ if page.PageToken != "" {
106+ var err error
107+ if after, err = parsePendingCursor(page.PageToken); err != nil {
108+ return ports.PendingIssuePage{}, err
109+ }
110+ }
111+ var all []domain.PendingIssue
112+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
113+ if err := ctx.Err(); err != nil {
114+ return ports.PendingIssuePage{}, err
115+ }
116+ r, id, err := splitPendingKey(string(key))
117+ if err != nil {
118+ return ports.PendingIssuePage{}, err
119+ }
120+ p, err := decodePending(r, id, item)
121+ if err != nil {
122+ return ports.PendingIssuePage{}, fmt.Errorf("decode %s: %w", key, err)
123+ }
124+ all = append(all, p)
125+ }
126+ slices.SortFunc(all, comparePending)
127+ if page.PageToken != "" {
128+ all = slices.DeleteFunc(all, func(p domain.PendingIssue) bool { return comparePending(p, after.item()) <= 0 })
129+ }
130+ var out ports.PendingIssuePage
131+ if page.PageSize > 0 && len(all) > page.PageSize {
132+ all = all[:page.PageSize]
133+ out.NextPageToken = encodePendingCursor(all[len(all)-1])
134+ }
135+ out.Issues = all
136+ return out, nil
137+}
138+
139+func comparePending(a, b domain.PendingIssue) int {
140+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
141+ return c
142+ }
143+ if c := strings.Compare(a.Repo.String(), b.Repo.String()); c != 0 {
144+ return c
145+ }
146+ return strings.Compare(a.ID.String(), b.ID.String())
147+}
148+
149+type pendingCursor struct {
150+ nanos int64
151+ repo domain.RepoName
152+ id domain.PendingIssueID
153+}
154+
155+func (c pendingCursor) item() domain.PendingIssue {
156+ return domain.PendingIssue{Repo: c.repo, ID: c.id, CreatedAt: time.Unix(0, c.nanos)}
157+}
158+
159+func encodePendingCursor(p domain.PendingIssue) string {
160+ return strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "/" + p.Repo.String() + "/" + p.ID.String()
161+}
162+
163+func parsePendingCursor(s string) (pendingCursor, error) {
164+ bad := fmt.Errorf("%w: page token", domain.ErrInvalid)
165+ parts := strings.Split(s, "/")
166+ if len(parts) != 3 {
167+ return pendingCursor{}, bad
168+ }
169+ n, err := strconv.ParseInt(parts[0], 10, 64)
170+ if err != nil {
171+ return pendingCursor{}, bad
172+ }
173+ repo, err := domain.ParseRepoName(parts[1])
174+ if err != nil {
175+ return pendingCursor{}, bad
176+ }
177+ id, err := domain.ParsePendingIssueID(parts[2])
178+ if err != nil {
179+ return pendingCursor{}, bad
180+ }
181+ return pendingCursor{nanos: n, repo: repo, id: id}, nil
182+}
183+
184+func splitPendingKey(key string) (domain.RepoName, domain.PendingIssueID, error) {
185+ rest := strings.TrimPrefix(key, pendingPrefix)
186+ repoName, idStr, ok := strings.Cut(rest, "/")
187+ if !ok {
188+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q", key)
189+ }
190+ repo, err := domain.ParseRepoName(repoName)
191+ if err != nil {
192+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
193+ }
194+ id, err := domain.ParsePendingIssueID(idStr)
195+ if err != nil {
196+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
197+ }
198+ return repo, id, nil
199+}
200+
201+// Count counts the keys under ispend/, stopping after MaxPendingIssues+1.
202+func (s *PendingIssues) Count(ctx context.Context) (int, error) {
203+ n := 0
204+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(pendingPrefix)) {
205+ if err := ctx.Err(); err != nil {
206+ return 0, err
207+ }
208+ n++
209+ if n > domain.MaxPendingIssues {
210+ break
211+ }
212+ }
213+ return n, nil
214+}
215+
216+// Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
217+// writes the open issue and its index key and deletes the pending record, in
218+// one batch that requires the pending record to be unchanged.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue
221+func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
222+ var number int64
223+ err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
224+ key := pendingKey(repo, id)
225+ item, ok := s.db.Get(ctx, key)
226+ if !ok {
227+ return domain.ErrNotFound
228+ }
229+ p, err := decodePending(repo, id, item)
230+ if err != nil {
231+ return err
232+ }
233+ issue := domain.ApprovedIssue(p, now)
234+ issue.Repo, issue.Number = repo, n
235+ val, err := encodeIssue(issue)
236+ if err != nil {
237+ return err
238+ }
239+ b.RequireVersion(key, item.Version)
240+ b.Delete(key)
241+ b.RequireAbsent(issueKey(repo, n))
242+ b.Put(issueKey(repo, n), val)
243+ indexOpenIssue(b, issue)
244+ number = n
245+ return nil
246+ })
247+ if err != nil {
248+ if errors.Is(err, domain.ErrNotFound) {
249+ return domain.Issue{}, domain.ErrNotFound
250+ }
251+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
252+ }
253+ return (&Issues{db: s.db, locks: s.locks}).Get(ctx, repo, number)
254+}
255+
256+// Delete removes the pending record, or returns domain.ErrNotFound.
257+func (s *PendingIssues) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
258+ key := pendingKey(repo, id)
259+ item, ok := s.db.Get(ctx, key)
260+ if !ok {
261+ return domain.ErrNotFound
262+ }
263+ var b kv.Batch
264+ b.RequireVersion(key, item.Version)
265+ b.Delete(key)
266+ if _, err := s.db.Apply(ctx, &b); err != nil {
267+ if errors.Is(err, kv.ErrConflict) {
268+ return domain.ErrNotFound // gone or approved meanwhile
269+ }
270+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
271+ }
272+ return nil
273+}
274+
275+func encodePending(p domain.PendingIssue) ([]byte, error) {
276+ if p.Repo.IsZero() {
277+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
278+ }
279+ if _, err := domain.ParsePendingIssueID(p.ID.String()); err != nil {
280+ return nil, err
281+ }
282+ if !utf8.ValidString(p.Author) {
283+ return nil, errors.New("pending issue author is not valid UTF-8")
284+ }
285+ if len(p.IPHash) != domain.IPHashLen {
286+ return nil, fmt.Errorf("%w: ip hash is %d bytes, want %d", domain.ErrInvalid, len(p.IPHash), domain.IPHashLen)
287+ }
288+ return mustMarshal(&storagepb.PendingIssue{
289+ Title: p.Title.String(), Body: p.Body.String(), Author: p.Author,
290+ CreatedAt: encodeTime(p.CreatedAt), IpHash: p.IPHash,
291+ }), nil
292+}
293+
294+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
295+func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
296+ var pb storagepb.PendingIssue
297+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
298+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue: %w", err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
307+ }
308+ return domain.PendingIssue{
309+ ID: id, Repo: repo, Title: title, Body: body, Author: pb.GetAuthor(),
310+ CreatedAt: decodeTime(pb.GetCreatedAt()), IPHash: slices.Clone(pb.GetIpHash()), Version: item.Version,
311+ }, nil
312+}
313+
314+// IssueKeys is the ermite-kv backed ports.IssueKeyStore.
315+type IssueKeys struct {
316+ db *kv.DB
317+}
318+
319+var _ ports.IssueKeyStore = (*IssueKeys)(nil)
320+
321+// IssueKeys returns the issue key store over the same database. It is valid
322+// until the Store is closed.
323+func (s *Store) IssueKeys() *IssueKeys { return &IssueKeys{db: s.db} }
324+
325+// Key reads meta/issuekey and generates it when absent. The key is never
326+// logged.
327+func (k *IssueKeys) Key(ctx context.Context) ([]byte, error) {
328+ for range updateAttempts {
329+ if item, ok := k.db.Get(ctx, []byte(issueKeyKey)); ok {
330+ var m storagepb.IssueKey
331+ if err := proto.Unmarshal(item.Value, &m); err != nil {
332+ return nil, fmt.Errorf("decode issue key: %w", err)
333+ }
334+ if len(m.GetKey()) != issueKeyLen {
335+ return nil, fmt.Errorf("decode issue key: %d bytes, want %d", len(m.GetKey()), issueKeyLen)
336+ }
337+ return append([]byte(nil), m.GetKey()...), nil
338+ }
339+ key := make([]byte, issueKeyLen)
340+ if _, err := rand.Read(key); err != nil {
341+ return nil, fmt.Errorf("generate issue key: %w", err)
342+ }
343+ var b kv.Batch
344+ b.RequireAbsent([]byte(issueKeyKey))
345+ b.Put([]byte(issueKeyKey), mustMarshal(&storagepb.IssueKey{Key: key}))
346+ _, err := k.db.Apply(ctx, &b)
347+ if errors.Is(err, kv.ErrConflict) {
348+ continue // written meanwhile: read it
349+ }
350+ if err != nil {
351+ return nil, fmt.Errorf("store issue key: %w", err)
352+ }
353+ log := zerowrap.FromCtx(ctx)
354+ log.Info().Msg("issue key generated")
355+ return key, nil
356+ }
357+ return nil, fmt.Errorf("store issue key: %w", domain.ErrConflict)
358+}
359+
360+// IssueBans is the ermite-kv backed ports.IssueBanStore.
361+type IssueBans struct {
362+ db *kv.DB
363+}
364+
365+var _ ports.IssueBanStore = (*IssueBans)(nil)
366+
367+// IssueBans returns the ban store over the same database. It is valid until
368+// the Store is closed.
369+func (s *Store) IssueBans() *IssueBans { return &IssueBans{db: s.db} }
370+
371+func banKey(hash []byte) []byte { return []byte(banPrefix + hex.EncodeToString(hash)) }
372+
373+// Ban writes isban/<hex hash> with its expiry.
374+func (s *IssueBans) Ban(ctx context.Context, hash []byte, until time.Time) error {
375+ if len(hash) != domain.IPHashLen {
376+ return fmt.Errorf("%w: ban hash is %d bytes, want %d", domain.ErrInvalid, len(hash), domain.IPHashLen)
377+ }
378+ var b kv.Batch
379+ b.Put(banKey(hash), mustMarshal(&storagepb.IssueBan{Until: encodeTime(until)}))
380+ if _, err := s.db.Apply(ctx, &b); err != nil {
381+ return fmt.Errorf("store ban: %w", err)
382+ }
383+ return nil
384+}
385+
386+// Banned reads the ban; one that expired is not a ban and is deleted (if it
387+// has not been replaced meanwhile).
388+func (s *IssueBans) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
389+ key := banKey(hash)
390+ item, ok := s.db.Get(ctx, key)
391+ if !ok {
392+ return false, nil
393+ }
394+ var pb storagepb.IssueBan
395+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
396+ return false, fmt.Errorf("decode ban: %w", err)
397+ }
398+ if now.Before(decodeTime(pb.GetUntil())) {
399+ return true, nil
400+ }
401+ var b kv.Batch
402+ b.RequireVersion(key, item.Version)
403+ b.Delete(key)
404+ if _, err := s.db.Apply(ctx, &b); err != nil && !errors.Is(err, kv.ErrConflict) {
405+ return false, fmt.Errorf("delete expired ban: %w", err)
406+ }
407+ return false, nil
408+}
@@ -0,0 +1,408 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/hex"
9+ "errors"
10+ "fmt"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+ "unicode/utf8"
16+
17+ kv "bnema.dev/ermite-kv"
18+ "github.com/bnema/zerowrap"
19+ "google.golang.org/protobuf/proto"
20+
21+ storagepb "bnema.dev/ermite/gen/ermite/storage"
22+ "bnema.dev/ermite/internal/domain"
23+ "bnema.dev/ermite/internal/ports"
24+)
25+
26+// Key layout of the public issue form.
27+const (
28+ pendingPrefix = "ispend/" // ispend/<repo>/<id>
29+ banPrefix = "isban/" // isban/<hex of the hash>
30+ issueKeyKey = "meta/issuekey"
31+ issueKeyLen = 32
32+)
33+
34+// PendingIssues is the ermite-kv backed ports.PendingIssueStore. It shares the
35+// database of the Store it comes from.
36+type PendingIssues struct {
37+ db *kv.DB
38+ locks *numberLocks
39+}
40+
41+var _ ports.PendingIssueStore = (*PendingIssues)(nil)
42+
43+// PendingIssues returns the pending issue store over the same database. It is
44+// valid until the Store is closed.
45+func (s *Store) PendingIssues() *PendingIssues { return &PendingIssues{db: s.db, locks: s.locks} }
46+
47+func pendingRepoPrefix(repo domain.RepoName) string { return pendingPrefix + repo.String() + "/" }
48+func pendingKey(repo domain.RepoName, id domain.PendingIssueID) []byte {
49+ return []byte(pendingRepoPrefix(repo) + id.String())
50+}
51+
52+// pendingKeys lists every key of the snapshot that holds a pending issue of
53+// the repository.
54+func pendingKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
55+ var keys [][]byte
56+ for k := range snap.Prefix(ctx, []byte(pendingRepoPrefix(repo))) {
57+ keys = append(keys, slices.Clone(k))
58+ }
59+ return keys
60+}
61+
62+// Put stores the pending issue under a new key. The repository must exist (see
63+// guardRepoKey): domain.ErrNotFound otherwise.
64+func (s *PendingIssues) Put(ctx context.Context, p domain.PendingIssue) error {
65+ val, err := encodePending(p)
66+ if err != nil {
67+ return err
68+ }
69+ var b kv.Batch
70+ if err := guardRepoKey(ctx, s.db, &b, p.Repo); err != nil {
71+ return err
72+ }
73+ b.RequireAbsent(pendingKey(p.Repo, p.ID))
74+ b.Put(pendingKey(p.Repo, p.ID), val)
75+ if _, err := s.db.Apply(ctx, &b); err != nil {
76+ if errors.Is(err, kv.ErrConflict) {
77+ return domain.ErrConflict
78+ }
79+ return fmt.Errorf("store pending issue of %q: %w", p.Repo, err)
80+ }
81+ return nil
82+}
83+
84+// Get reads ispend/<repo>/<id>.
85+func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) (domain.PendingIssue, error) {
86+ item, ok := s.db.Get(ctx, pendingKey(repo, id))
87+ if !ok {
88+ return domain.PendingIssue{}, domain.ErrNotFound
89+ }
90+ return decodePending(repo, id, item)
91+}
92+
93+// List reads the pending issues of the repository, or of all, sorted by
94+// creation time then repository then id. There are at most
95+// domain.MaxPendingIssues of them (a little more under a race), so they are
96+// read and sorted. The page token is the position of the last item returned.
97+//
98+//hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy
99+func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) {
100+ prefix := pendingPrefix
101+ if !repo.IsZero() {
102+ prefix = pendingRepoPrefix(repo)
103+ }
104+ var after pendingCursor
105+ if page.PageToken != "" {
106+ var err error
107+ if after, err = parsePendingCursor(page.PageToken); err != nil {
108+ return ports.PendingIssuePage{}, err
109+ }
110+ }
111+ var all []domain.PendingIssue
112+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
113+ if err := ctx.Err(); err != nil {
114+ return ports.PendingIssuePage{}, err
115+ }
116+ r, id, err := splitPendingKey(string(key))
117+ if err != nil {
118+ return ports.PendingIssuePage{}, err
119+ }
120+ p, err := decodePending(r, id, item)
121+ if err != nil {
122+ return ports.PendingIssuePage{}, fmt.Errorf("decode %s: %w", key, err)
123+ }
124+ all = append(all, p)
125+ }
126+ slices.SortFunc(all, comparePending)
127+ if page.PageToken != "" {
128+ all = slices.DeleteFunc(all, func(p domain.PendingIssue) bool { return comparePending(p, after.item()) <= 0 })
129+ }
130+ var out ports.PendingIssuePage
131+ if page.PageSize > 0 && len(all) > page.PageSize {
132+ all = all[:page.PageSize]
133+ out.NextPageToken = encodePendingCursor(all[len(all)-1])
134+ }
135+ out.Issues = all
136+ return out, nil
137+}
138+
139+func comparePending(a, b domain.PendingIssue) int {
140+ if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
141+ return c
142+ }
143+ if c := strings.Compare(a.Repo.String(), b.Repo.String()); c != 0 {
144+ return c
145+ }
146+ return strings.Compare(a.ID.String(), b.ID.String())
147+}
148+
149+type pendingCursor struct {
150+ nanos int64
151+ repo domain.RepoName
152+ id domain.PendingIssueID
153+}
154+
155+func (c pendingCursor) item() domain.PendingIssue {
156+ return domain.PendingIssue{Repo: c.repo, ID: c.id, CreatedAt: time.Unix(0, c.nanos)}
157+}
158+
159+func encodePendingCursor(p domain.PendingIssue) string {
160+ return strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "/" + p.Repo.String() + "/" + p.ID.String()
161+}
162+
163+func parsePendingCursor(s string) (pendingCursor, error) {
164+ bad := fmt.Errorf("%w: page token", domain.ErrInvalid)
165+ parts := strings.Split(s, "/")
166+ if len(parts) != 3 {
167+ return pendingCursor{}, bad
168+ }
169+ n, err := strconv.ParseInt(parts[0], 10, 64)
170+ if err != nil {
171+ return pendingCursor{}, bad
172+ }
173+ repo, err := domain.ParseRepoName(parts[1])
174+ if err != nil {
175+ return pendingCursor{}, bad
176+ }
177+ id, err := domain.ParsePendingIssueID(parts[2])
178+ if err != nil {
179+ return pendingCursor{}, bad
180+ }
181+ return pendingCursor{nanos: n, repo: repo, id: id}, nil
182+}
183+
184+func splitPendingKey(key string) (domain.RepoName, domain.PendingIssueID, error) {
185+ rest := strings.TrimPrefix(key, pendingPrefix)
186+ repoName, idStr, ok := strings.Cut(rest, "/")
187+ if !ok {
188+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q", key)
189+ }
190+ repo, err := domain.ParseRepoName(repoName)
191+ if err != nil {
192+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
193+ }
194+ id, err := domain.ParsePendingIssueID(idStr)
195+ if err != nil {
196+ return domain.RepoName{}, "", fmt.Errorf("pending issue key %q: %w", key, err)
197+ }
198+ return repo, id, nil
199+}
200+
201+// Count counts the keys under ispend/, stopping after MaxPendingIssues+1.
202+func (s *PendingIssues) Count(ctx context.Context) (int, error) {
203+ n := 0
204+ for range s.db.Snapshot(ctx).Prefix(ctx, []byte(pendingPrefix)) {
205+ if err := ctx.Err(); err != nil {
206+ return 0, err
207+ }
208+ n++
209+ if n > domain.MaxPendingIssues {
210+ break
211+ }
212+ }
213+ return n, nil
214+}
215+
216+// Approve numbers the pending issue from mrseq/<repo> (see createNumbered),
217+// writes the open issue and its index key and deletes the pending record, in
218+// one batch that requires the pending record to be unchanged.
219+//
220+//hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue
221+func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) {
222+ var number int64
223+ err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error {
224+ key := pendingKey(repo, id)
225+ item, ok := s.db.Get(ctx, key)
226+ if !ok {
227+ return domain.ErrNotFound
228+ }
229+ p, err := decodePending(repo, id, item)
230+ if err != nil {
231+ return err
232+ }
233+ issue := domain.ApprovedIssue(p, now)
234+ issue.Repo, issue.Number = repo, n
235+ val, err := encodeIssue(issue)
236+ if err != nil {
237+ return err
238+ }
239+ b.RequireVersion(key, item.Version)
240+ b.Delete(key)
241+ b.RequireAbsent(issueKey(repo, n))
242+ b.Put(issueKey(repo, n), val)
243+ indexOpenIssue(b, issue)
244+ number = n
245+ return nil
246+ })
247+ if err != nil {
248+ if errors.Is(err, domain.ErrNotFound) {
249+ return domain.Issue{}, domain.ErrNotFound
250+ }
251+ return domain.Issue{}, fmt.Errorf("approve pending issue of %q: %w", repo, err)
252+ }
253+ return (&Issues{db: s.db, locks: s.locks}).Get(ctx, repo, number)
254+}
255+
256+// Delete removes the pending record, or returns domain.ErrNotFound.
257+func (s *PendingIssues) Delete(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID) error {
258+ key := pendingKey(repo, id)
259+ item, ok := s.db.Get(ctx, key)
260+ if !ok {
261+ return domain.ErrNotFound
262+ }
263+ var b kv.Batch
264+ b.RequireVersion(key, item.Version)
265+ b.Delete(key)
266+ if _, err := s.db.Apply(ctx, &b); err != nil {
267+ if errors.Is(err, kv.ErrConflict) {
268+ return domain.ErrNotFound // gone or approved meanwhile
269+ }
270+ return fmt.Errorf("delete pending issue of %q: %w", repo, err)
271+ }
272+ return nil
273+}
274+
275+func encodePending(p domain.PendingIssue) ([]byte, error) {
276+ if p.Repo.IsZero() {
277+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
278+ }
279+ if _, err := domain.ParsePendingIssueID(p.ID.String()); err != nil {
280+ return nil, err
281+ }
282+ if !utf8.ValidString(p.Author) {
283+ return nil, errors.New("pending issue author is not valid UTF-8")
284+ }
285+ if len(p.IPHash) != domain.IPHashLen {
286+ return nil, fmt.Errorf("%w: ip hash is %d bytes, want %d", domain.ErrInvalid, len(p.IPHash), domain.IPHashLen)
287+ }
288+ return mustMarshal(&storagepb.PendingIssue{
289+ Title: p.Title.String(), Body: p.Body.String(), Author: p.Author,
290+ CreatedAt: encodeTime(p.CreatedAt), IpHash: p.IPHash,
291+ }), nil
292+}
293+
294+//hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy
295+func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) {
296+ var pb storagepb.PendingIssue
297+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
298+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue: %w", err)
299+ }
300+ title, err := domain.ParseIssueTitle(pb.GetTitle())
301+ if err != nil {
302+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
303+ }
304+ body, err := domain.ParseIssueBody(pb.GetBody())
305+ if err != nil {
306+ return domain.PendingIssue{}, fmt.Errorf("decode pending issue %s of %q: %w", id, repo, err)
307+ }
308+ return domain.PendingIssue{
309+ ID: id, Repo: repo, Title: title, Body: body, Author: pb.GetAuthor(),
310+ CreatedAt: decodeTime(pb.GetCreatedAt()), IPHash: slices.Clone(pb.GetIpHash()), Version: item.Version,
311+ }, nil
312+}
313+
314+// IssueKeys is the ermite-kv backed ports.IssueKeyStore.
315+type IssueKeys struct {
316+ db *kv.DB
317+}
318+
319+var _ ports.IssueKeyStore = (*IssueKeys)(nil)
320+
321+// IssueKeys returns the issue key store over the same database. It is valid
322+// until the Store is closed.
323+func (s *Store) IssueKeys() *IssueKeys { return &IssueKeys{db: s.db} }
324+
325+// Key reads meta/issuekey and generates it when absent. The key is never
326+// logged.
327+func (k *IssueKeys) Key(ctx context.Context) ([]byte, error) {
328+ for range updateAttempts {
329+ if item, ok := k.db.Get(ctx, []byte(issueKeyKey)); ok {
330+ var m storagepb.IssueKey
331+ if err := proto.Unmarshal(item.Value, &m); err != nil {
332+ return nil, fmt.Errorf("decode issue key: %w", err)
333+ }
334+ if len(m.GetKey()) != issueKeyLen {
335+ return nil, fmt.Errorf("decode issue key: %d bytes, want %d", len(m.GetKey()), issueKeyLen)
336+ }
337+ return append([]byte(nil), m.GetKey()...), nil
338+ }
339+ key := make([]byte, issueKeyLen)
340+ if _, err := rand.Read(key); err != nil {
341+ return nil, fmt.Errorf("generate issue key: %w", err)
342+ }
343+ var b kv.Batch
344+ b.RequireAbsent([]byte(issueKeyKey))
345+ b.Put([]byte(issueKeyKey), mustMarshal(&storagepb.IssueKey{Key: key}))
346+ _, err := k.db.Apply(ctx, &b)
347+ if errors.Is(err, kv.ErrConflict) {
348+ continue // written meanwhile: read it
349+ }
350+ if err != nil {
351+ return nil, fmt.Errorf("store issue key: %w", err)
352+ }
353+ log := zerowrap.FromCtx(ctx)
354+ log.Info().Msg("issue key generated")
355+ return key, nil
356+ }
357+ return nil, fmt.Errorf("store issue key: %w", domain.ErrConflict)
358+}
359+
360+// IssueBans is the ermite-kv backed ports.IssueBanStore.
361+type IssueBans struct {
362+ db *kv.DB
363+}
364+
365+var _ ports.IssueBanStore = (*IssueBans)(nil)
366+
367+// IssueBans returns the ban store over the same database. It is valid until
368+// the Store is closed.
369+func (s *Store) IssueBans() *IssueBans { return &IssueBans{db: s.db} }
370+
371+func banKey(hash []byte) []byte { return []byte(banPrefix + hex.EncodeToString(hash)) }
372+
373+// Ban writes isban/<hex hash> with its expiry.
374+func (s *IssueBans) Ban(ctx context.Context, hash []byte, until time.Time) error {
375+ if len(hash) != domain.IPHashLen {
376+ return fmt.Errorf("%w: ban hash is %d bytes, want %d", domain.ErrInvalid, len(hash), domain.IPHashLen)
377+ }
378+ var b kv.Batch
379+ b.Put(banKey(hash), mustMarshal(&storagepb.IssueBan{Until: encodeTime(until)}))
380+ if _, err := s.db.Apply(ctx, &b); err != nil {
381+ return fmt.Errorf("store ban: %w", err)
382+ }
383+ return nil
384+}
385+
386+// Banned reads the ban; one that expired is not a ban and is deleted (if it
387+// has not been replaced meanwhile).
388+func (s *IssueBans) Banned(ctx context.Context, hash []byte, now time.Time) (bool, error) {
389+ key := banKey(hash)
390+ item, ok := s.db.Get(ctx, key)
391+ if !ok {
392+ return false, nil
393+ }
394+ var pb storagepb.IssueBan
395+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
396+ return false, fmt.Errorf("decode ban: %w", err)
397+ }
398+ if now.Before(decodeTime(pb.GetUntil())) {
399+ return true, nil
400+ }
401+ var b kv.Batch
402+ b.RequireVersion(key, item.Version)
403+ b.Delete(key)
404+ if _, err := s.db.Apply(ctx, &b); err != nil && !errors.Is(err, kv.ErrConflict) {
405+ return false, fmt.Errorf("delete expired ban: %w", err)
406+ }
407+ return false, nil
408+}
A internal/adapters/kvstore/issues_pending_test.go +271 -0
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20+ t.Helper()
21+ id, err := domain.NewPendingIssueID()
22+ require.NoError(t, err)
23+ ti, err := domain.ParseIssueTitle(title)
24+ require.NoError(t, err)
25+ body, err := domain.ParseIssueBody("details")
26+ require.NoError(t, err)
27+ return domain.PendingIssue{
28+ ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29+ IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30+ }
31+}
32+
33+func TestPendingIssuePutGetListCount(t *testing.T) {
34+ ctx := context.Background()
35+ s, _, n := repoWithMRs(t)
36+ other := name(t, "other")
37+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38+ ps := s.PendingIssues()
39+
40+ a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41+ b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42+ c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43+ for _, p := range []domain.PendingIssue{c, a, b} {
44+ require.NoError(t, ps.Put(ctx, p))
45+ }
46+ require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47+ require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48+
49+ got, err := ps.Get(ctx, n, a.ID)
50+ require.NoError(t, err)
51+ assert.Equal(t, a.Title, got.Title)
52+ assert.Equal(t, "Alex", got.Author)
53+ assert.Equal(t, a.IPHash, got.IPHash)
54+ assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55+ assert.NotZero(t, got.Version)
56+ _, err = ps.Get(ctx, other, a.ID)
57+ assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58+
59+ all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60+ require.NoError(t, err)
61+ require.Len(t, all.Issues, 3)
62+ assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63+
64+ mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65+ require.NoError(t, err)
66+ assert.Len(t, mine.Issues, 2)
67+
68+ p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69+ require.NoError(t, err)
70+ require.Len(t, p1.Issues, 2)
71+ require.NotEmpty(t, p1.NextPageToken)
72+ p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73+ require.NoError(t, err)
74+ require.Len(t, p2.Issues, 1)
75+ assert.Equal(t, c.ID, p2.Issues[0].ID)
76+ assert.Empty(t, p2.NextPageToken)
77+ _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78+ assert.ErrorIs(t, err, domain.ErrInvalid)
79+
80+ cnt, err := ps.Count(ctx)
81+ require.NoError(t, err)
82+ assert.Equal(t, 3, cnt)
83+
84+ require.NoError(t, ps.Delete(ctx, n, a.ID))
85+ assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86+ cnt, err = ps.Count(ctx)
87+ require.NoError(t, err)
88+ assert.Equal(t, 2, cnt)
89+}
90+
91+func TestPendingIssueCountIsCapped(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ ps := s.PendingIssues()
95+ for i := range domain.MaxPendingIssues + 5 {
96+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97+ }
98+ cnt, err := ps.Count(ctx)
99+ require.NoError(t, err)
100+ assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101+}
102+
103+func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104+ ctx := context.Background()
105+ s, mrs, n := repoWithMRs(t)
106+ ps := s.PendingIssues()
107+
108+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(1), mr.Number)
111+
112+ named := newPending(t, n, "named", "Alex", created)
113+ anon := newPending(t, n, "anon", "", created.Add(time.Second))
114+ require.NoError(t, ps.Put(ctx, named))
115+ require.NoError(t, ps.Put(ctx, anon))
116+
117+ issue, err := ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
118+ require.NoError(t, err)
119+ assert.Equal(t, int64(2), issue.Number)
120+ assert.Equal(t, domain.IssueStateOpen, issue.State)
121+ assert.Equal(t, "Alex", issue.Author)
122+ assert.Equal(t, "named", issue.Title.String())
123+ assert.True(t, issue.External, "an approved submission is external")
124+
125+ _, err = ps.Get(ctx, n, named.ID)
126+ assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
127+ _, err = ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
128+ assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
129+
130+ got, err := s.Issues().Get(ctx, n, 2)
131+ require.NoError(t, err)
132+ assert.Equal(t, issue.Title, got.Title)
133+ assert.True(t, got.External, "the flag is stored")
134+ open := openOf(t, s.Issues(), n)
135+ require.Len(t, open, 1)
136+ assert.Equal(t, int64(2), open[0].Number)
137+
138+ second, err := ps.Approve(ctx, n, anon.ID, created.Add(time.Hour))
139+ require.NoError(t, err)
140+ assert.Equal(t, int64(3), second.Number)
141+ assert.Equal(t, "anonymous", second.Author)
142+
143+ next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
144+ require.NoError(t, err)
145+ assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
146+ assert.False(t, next.External, "an issue of the owner is not external")
147+}
148+
149+func TestPendingIssueApproveConcurrent(t *testing.T) {
150+ ctx := context.Background()
151+ s, mrs, n := repoWithMRs(t)
152+ ps := s.PendingIssues()
153+ const k = 8
154+ ids := make([]domain.PendingIssueID, k)
155+ for i := range k {
156+ p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
157+ ids[i] = p.ID
158+ require.NoError(t, ps.Put(ctx, p))
159+ }
160+ var mu sync.Mutex
161+ seen := map[int64]bool{}
162+ var wg sync.WaitGroup
163+ for _, id := range ids {
164+ wg.Add(1)
165+ go func() {
166+ defer wg.Done()
167+ is, err := ps.Approve(ctx, n, id, created.Add(time.Hour))
168+ if assert.NoError(t, err) {
169+ mu.Lock()
170+ seen[is.Number] = true
171+ mu.Unlock()
172+ }
173+ }()
174+ }
175+ wg.Add(1)
176+ go func() {
177+ defer wg.Done()
178+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
179+ if assert.NoError(t, err) {
180+ mu.Lock()
181+ seen[mr.Number] = true
182+ mu.Unlock()
183+ }
184+ }()
185+ wg.Wait()
186+ assert.Len(t, seen, k+1, "no number is given twice")
187+}
188+
189+func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
190+ ctx := context.Background()
191+ s, _, n := repoWithMRs(t)
192+ ps := s.PendingIssues()
193+ p := newPending(t, n, "t", "", created)
194+ require.NoError(t, ps.Put(ctx, p))
195+ require.NoError(t, s.Delete(ctx, n))
196+
197+ _, err := ps.Approve(ctx, n, p.ID, created.Add(time.Hour))
198+ assert.ErrorIs(t, err, domain.ErrNotFound)
199+ assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
200+ assert.Zero(t, countKeys(t, s, "is/"))
201+ assert.Zero(t, countKeys(t, s, "isopen/"))
202+}
203+
204+func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
205+ ctx := context.Background()
206+ s, _, n := repoWithMRs(t)
207+ other := name(t, "other")
208+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
209+ ps := s.PendingIssues()
210+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
211+ require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
212+ keep := newPending(t, other, "k", "", created)
213+ require.NoError(t, ps.Put(ctx, keep))
214+
215+ require.NoError(t, s.Delete(ctx, n))
216+ assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
217+ _, err := ps.Get(ctx, other, keep.ID)
218+ assert.NoError(t, err, "another repository keeps its pending issues")
219+ cnt, err := ps.Count(ctx)
220+ require.NoError(t, err)
221+ assert.Equal(t, 1, cnt)
222+}
223+
224+func TestIssueBansExpire(t *testing.T) {
225+ ctx := context.Background()
226+ s := openStore(t, t.TempDir())
227+ bans := s.IssueBans()
228+ h := bytes.Repeat([]byte{1}, domain.IPHashLen)
229+ other := bytes.Repeat([]byte{2}, domain.IPHashLen)
230+ now := created
231+
232+ banned, err := bans.Banned(ctx, h, now)
233+ require.NoError(t, err)
234+ assert.False(t, banned)
235+
236+ require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
237+ require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
238+ banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
239+ require.NoError(t, err)
240+ assert.True(t, banned)
241+ banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
242+ require.NoError(t, err)
243+ assert.False(t, banned, "another hash is not banned")
244+ assert.Equal(t, 1, countKeys(t, s, "isban/"))
245+
246+ banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
247+ require.NoError(t, err)
248+ assert.False(t, banned, "expired")
249+ assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
250+}
251+
252+func TestIssueKeyPersists(t *testing.T) {
253+ ctx := context.Background()
254+ dir := t.TempDir()
255+ s := openStore(t, dir)
256+ key, err := s.IssueKeys().Key(ctx)
257+ require.NoError(t, err)
258+ assert.Len(t, key, 32)
259+ again, err := s.IssueKeys().Key(ctx)
260+ require.NoError(t, err)
261+ assert.Equal(t, key, again)
262+ sess, err := s.Sessions().Key(ctx)
263+ require.NoError(t, err)
264+ assert.NotEqual(t, sess, key, "not the session key")
265+ require.NoError(t, s.Close(ctx))
266+
267+ s = openStore(t, dir)
268+ reopened, err := s.IssueKeys().Key(ctx)
269+ require.NoError(t, err)
270+ assert.Equal(t, key, reopened, "a restart keeps the key")
271+}
@@ -0,0 +1,271 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+func newPending(t *testing.T, repo domain.RepoName, title, author string, at time.Time) domain.PendingIssue {
20+ t.Helper()
21+ id, err := domain.NewPendingIssueID()
22+ require.NoError(t, err)
23+ ti, err := domain.ParseIssueTitle(title)
24+ require.NoError(t, err)
25+ body, err := domain.ParseIssueBody("details")
26+ require.NoError(t, err)
27+ return domain.PendingIssue{
28+ ID: id, Repo: repo, Title: ti, Body: body, Author: author, CreatedAt: at,
29+ IPHash: bytes.Repeat([]byte{7}, domain.IPHashLen),
30+ }
31+}
32+
33+func TestPendingIssuePutGetListCount(t *testing.T) {
34+ ctx := context.Background()
35+ s, _, n := repoWithMRs(t)
36+ other := name(t, "other")
37+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
38+ ps := s.PendingIssues()
39+
40+ a := newPending(t, n, "a", "Alex", created.Add(time.Minute))
41+ b := newPending(t, other, "b", "", created.Add(2*time.Minute))
42+ c := newPending(t, n, "c", "", created.Add(3*time.Minute))
43+ for _, p := range []domain.PendingIssue{c, a, b} {
44+ require.NoError(t, ps.Put(ctx, p))
45+ }
46+ require.ErrorIs(t, ps.Put(ctx, a), domain.ErrConflict, "an id is used once")
47+ require.ErrorIs(t, ps.Put(ctx, newPending(t, name(t, "ghost"), "g", "", created)), domain.ErrNotFound)
48+
49+ got, err := ps.Get(ctx, n, a.ID)
50+ require.NoError(t, err)
51+ assert.Equal(t, a.Title, got.Title)
52+ assert.Equal(t, "Alex", got.Author)
53+ assert.Equal(t, a.IPHash, got.IPHash)
54+ assert.True(t, got.CreatedAt.Equal(a.CreatedAt))
55+ assert.NotZero(t, got.Version)
56+ _, err = ps.Get(ctx, other, a.ID)
57+ assert.ErrorIs(t, err, domain.ErrNotFound, "ids are per repository")
58+
59+ all, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{})
60+ require.NoError(t, err)
61+ require.Len(t, all.Issues, 3)
62+ assert.Equal(t, []domain.PendingIssueID{a.ID, b.ID, c.ID}, []domain.PendingIssueID{all.Issues[0].ID, all.Issues[1].ID, all.Issues[2].ID}, "oldest first")
63+
64+ mine, err := ps.List(ctx, n, ports.ListPendingParams{})
65+ require.NoError(t, err)
66+ assert.Len(t, mine.Issues, 2)
67+
68+ p1, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2})
69+ require.NoError(t, err)
70+ require.Len(t, p1.Issues, 2)
71+ require.NotEmpty(t, p1.NextPageToken)
72+ p2, err := ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: 2, PageToken: p1.NextPageToken})
73+ require.NoError(t, err)
74+ require.Len(t, p2.Issues, 1)
75+ assert.Equal(t, c.ID, p2.Issues[0].ID)
76+ assert.Empty(t, p2.NextPageToken)
77+ _, err = ps.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageToken: "x"})
78+ assert.ErrorIs(t, err, domain.ErrInvalid)
79+
80+ cnt, err := ps.Count(ctx)
81+ require.NoError(t, err)
82+ assert.Equal(t, 3, cnt)
83+
84+ require.NoError(t, ps.Delete(ctx, n, a.ID))
85+ assert.ErrorIs(t, ps.Delete(ctx, n, a.ID), domain.ErrNotFound)
86+ cnt, err = ps.Count(ctx)
87+ require.NoError(t, err)
88+ assert.Equal(t, 2, cnt)
89+}
90+
91+func TestPendingIssueCountIsCapped(t *testing.T) {
92+ ctx := context.Background()
93+ s, _, n := repoWithMRs(t)
94+ ps := s.PendingIssues()
95+ for i := range domain.MaxPendingIssues + 5 {
96+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))))
97+ }
98+ cnt, err := ps.Count(ctx)
99+ require.NoError(t, err)
100+ assert.Equal(t, domain.MaxPendingIssues+1, cnt)
101+}
102+
103+func TestPendingIssueApproveSharesNumbers(t *testing.T) {
104+ ctx := context.Background()
105+ s, mrs, n := repoWithMRs(t)
106+ ps := s.PendingIssues()
107+
108+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(1), mr.Number)
111+
112+ named := newPending(t, n, "named", "Alex", created)
113+ anon := newPending(t, n, "anon", "", created.Add(time.Second))
114+ require.NoError(t, ps.Put(ctx, named))
115+ require.NoError(t, ps.Put(ctx, anon))
116+
117+ issue, err := ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
118+ require.NoError(t, err)
119+ assert.Equal(t, int64(2), issue.Number)
120+ assert.Equal(t, domain.IssueStateOpen, issue.State)
121+ assert.Equal(t, "Alex", issue.Author)
122+ assert.Equal(t, "named", issue.Title.String())
123+ assert.True(t, issue.External, "an approved submission is external")
124+
125+ _, err = ps.Get(ctx, n, named.ID)
126+ assert.ErrorIs(t, err, domain.ErrNotFound, "the pending record is gone")
127+ _, err = ps.Approve(ctx, n, named.ID, created.Add(time.Hour))
128+ assert.ErrorIs(t, err, domain.ErrNotFound, "approved once")
129+
130+ got, err := s.Issues().Get(ctx, n, 2)
131+ require.NoError(t, err)
132+ assert.Equal(t, issue.Title, got.Title)
133+ assert.True(t, got.External, "the flag is stored")
134+ open := openOf(t, s.Issues(), n)
135+ require.Len(t, open, 1)
136+ assert.Equal(t, int64(2), open[0].Number)
137+
138+ second, err := ps.Approve(ctx, n, anon.ID, created.Add(time.Hour))
139+ require.NoError(t, err)
140+ assert.Equal(t, int64(3), second.Number)
141+ assert.Equal(t, "anonymous", second.Author)
142+
143+ next, err := s.Issues().Create(ctx, newIssue(t, n, "owner"))
144+ require.NoError(t, err)
145+ assert.Equal(t, int64(4), next.Number, "issues and merge requests share the counter")
146+ assert.False(t, next.External, "an issue of the owner is not external")
147+}
148+
149+func TestPendingIssueApproveConcurrent(t *testing.T) {
150+ ctx := context.Background()
151+ s, mrs, n := repoWithMRs(t)
152+ ps := s.PendingIssues()
153+ const k = 8
154+ ids := make([]domain.PendingIssueID, k)
155+ for i := range k {
156+ p := newPending(t, n, "t", "", created.Add(time.Duration(i)*time.Second))
157+ ids[i] = p.ID
158+ require.NoError(t, ps.Put(ctx, p))
159+ }
160+ var mu sync.Mutex
161+ seen := map[int64]bool{}
162+ var wg sync.WaitGroup
163+ for _, id := range ids {
164+ wg.Add(1)
165+ go func() {
166+ defer wg.Done()
167+ is, err := ps.Approve(ctx, n, id, created.Add(time.Hour))
168+ if assert.NoError(t, err) {
169+ mu.Lock()
170+ seen[is.Number] = true
171+ mu.Unlock()
172+ }
173+ }()
174+ }
175+ wg.Add(1)
176+ go func() {
177+ defer wg.Done()
178+ mr, err := mrs.Create(ctx, newMR(t, n, "mr"))
179+ if assert.NoError(t, err) {
180+ mu.Lock()
181+ seen[mr.Number] = true
182+ mu.Unlock()
183+ }
184+ }()
185+ wg.Wait()
186+ assert.Len(t, seen, k+1, "no number is given twice")
187+}
188+
189+func TestPendingIssueApproveOnDeletedRepoFails(t *testing.T) {
190+ ctx := context.Background()
191+ s, _, n := repoWithMRs(t)
192+ ps := s.PendingIssues()
193+ p := newPending(t, n, "t", "", created)
194+ require.NoError(t, ps.Put(ctx, p))
195+ require.NoError(t, s.Delete(ctx, n))
196+
197+ _, err := ps.Approve(ctx, n, p.ID, created.Add(time.Hour))
198+ assert.ErrorIs(t, err, domain.ErrNotFound)
199+ assert.ErrorIs(t, ps.Put(ctx, newPending(t, n, "t", "", created)), domain.ErrNotFound)
200+ assert.Zero(t, countKeys(t, s, "is/"))
201+ assert.Zero(t, countKeys(t, s, "isopen/"))
202+}
203+
204+func TestRepoDeleteRemovesPendingIssues(t *testing.T) {
205+ ctx := context.Background()
206+ s, _, n := repoWithMRs(t)
207+ other := name(t, "other")
208+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
209+ ps := s.PendingIssues()
210+ require.NoError(t, ps.Put(ctx, newPending(t, n, "t", "", created)))
211+ require.NoError(t, ps.Put(ctx, newPending(t, n, "u", "", created)))
212+ keep := newPending(t, other, "k", "", created)
213+ require.NoError(t, ps.Put(ctx, keep))
214+
215+ require.NoError(t, s.Delete(ctx, n))
216+ assert.Zero(t, countKeys(t, s, "ispend/"+n.String()+"/"))
217+ _, err := ps.Get(ctx, other, keep.ID)
218+ assert.NoError(t, err, "another repository keeps its pending issues")
219+ cnt, err := ps.Count(ctx)
220+ require.NoError(t, err)
221+ assert.Equal(t, 1, cnt)
222+}
223+
224+func TestIssueBansExpire(t *testing.T) {
225+ ctx := context.Background()
226+ s := openStore(t, t.TempDir())
227+ bans := s.IssueBans()
228+ h := bytes.Repeat([]byte{1}, domain.IPHashLen)
229+ other := bytes.Repeat([]byte{2}, domain.IPHashLen)
230+ now := created
231+
232+ banned, err := bans.Banned(ctx, h, now)
233+ require.NoError(t, err)
234+ assert.False(t, banned)
235+
236+ require.NoError(t, bans.Ban(ctx, h, now.Add(domain.BanDuration)))
237+ require.ErrorIs(t, bans.Ban(ctx, []byte("short"), now), domain.ErrInvalid)
238+ banned, err = bans.Banned(ctx, h, now.Add(time.Hour))
239+ require.NoError(t, err)
240+ assert.True(t, banned)
241+ banned, err = bans.Banned(ctx, other, now.Add(time.Hour))
242+ require.NoError(t, err)
243+ assert.False(t, banned, "another hash is not banned")
244+ assert.Equal(t, 1, countKeys(t, s, "isban/"))
245+
246+ banned, err = bans.Banned(ctx, h, now.Add(domain.BanDuration))
247+ require.NoError(t, err)
248+ assert.False(t, banned, "expired")
249+ assert.Zero(t, countKeys(t, s, "isban/"), "an expired ban is deleted")
250+}
251+
252+func TestIssueKeyPersists(t *testing.T) {
253+ ctx := context.Background()
254+ dir := t.TempDir()
255+ s := openStore(t, dir)
256+ key, err := s.IssueKeys().Key(ctx)
257+ require.NoError(t, err)
258+ assert.Len(t, key, 32)
259+ again, err := s.IssueKeys().Key(ctx)
260+ require.NoError(t, err)
261+ assert.Equal(t, key, again)
262+ sess, err := s.Sessions().Key(ctx)
263+ require.NoError(t, err)
264+ assert.NotEqual(t, sess, key, "not the session key")
265+ require.NoError(t, s.Close(ctx))
266+
267+ s = openStore(t, dir)
268+ reopened, err := s.IssueKeys().Key(ctx)
269+ require.NoError(t, err)
270+ assert.Equal(t, key, reopened, "a restart keeps the key")
271+}
A internal/adapters/kvstore/issues_test.go +561 -0
@@ -0,0 +1,561 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "slices"
8+ "sort"
9+ "strconv"
10+ "sync"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/adapters/kvstore"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
24+ t.Helper()
25+ ti, err := domain.ParseIssueTitle(title)
26+ require.NoError(t, err)
27+ body, err := domain.ParseIssueBody("what\nhappened")
28+ require.NoError(t, err)
29+ return domain.Issue{
30+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
31+ Author: "agent", CreatedAt: created, UpdatedAt: created,
32+ }
33+}
34+
35+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
36+ t.Helper()
37+ body, err := domain.ParseIssueBody(text)
38+ require.NoError(t, err)
39+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
40+}
41+
42+// countKeys counts the keys of the store under prefix.
43+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
44+ t.Helper()
45+ return s.CountKeysForTest(context.Background(), prefix)
46+}
47+
48+func TestIssueRoundTripAndPaging(t *testing.T) {
49+ ctx := context.Background()
50+ s, _, n := repoWithMRs(t)
51+ is := s.Issues()
52+
53+ first, err := is.Create(ctx, newIssue(t, n, "one"))
54+ require.NoError(t, err)
55+ assert.Equal(t, int64(1), first.Number)
56+ assert.NotZero(t, first.Version)
57+ got, err := is.Get(ctx, n, 1)
58+ require.NoError(t, err)
59+ assert.Equal(t, first, got)
60+ _, err = is.Get(ctx, n, 2)
61+ assert.ErrorIs(t, err, domain.ErrNotFound)
62+
63+ for _, title := range []string{"two", "three", "four"} {
64+ _, err := is.Create(ctx, newIssue(t, n, title))
65+ require.NoError(t, err)
66+ }
67+ closed, err := is.Get(ctx, n, 2)
68+ require.NoError(t, err)
69+ closed.State = domain.IssueStateClosed
70+ require.NoError(t, is.Update(ctx, closed))
71+
72+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
73+ require.NoError(t, err)
74+ require.Len(t, page.Issues, 3)
75+ assert.Equal(t, int64(4), page.Issues[0].Number)
76+ require.NotEmpty(t, page.NextPageToken)
77+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
78+ require.NoError(t, err)
79+ require.Len(t, page.Issues, 1)
80+ assert.Equal(t, int64(1), page.Issues[0].Number)
81+ assert.Empty(t, page.NextPageToken)
82+
83+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
84+ require.NoError(t, err)
85+ require.Len(t, page.Issues, 1)
86+ assert.Equal(t, int64(2), page.Issues[0].Number)
87+
88+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
89+ assert.ErrorIs(t, err, domain.ErrInvalid)
90+
91+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
92+ assert.ErrorIs(t, err, domain.ErrNotFound)
93+}
94+
95+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
96+ ctx := context.Background()
97+ s, _, n := repoWithMRs(t)
98+ is := s.Issues()
99+ first, err := is.Create(ctx, newIssue(t, n, "t"))
100+ require.NoError(t, err)
101+ a, b := first, first
102+ a.State = domain.IssueStateClosed
103+ require.NoError(t, is.Update(ctx, a))
104+ b.State = domain.IssueStateClosed
105+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
106+}
107+
108+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
109+ ctx := context.Background()
110+ s, _, n := repoWithMRs(t)
111+ is := s.Issues()
112+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
113+ require.NoError(t, err)
114+
115+ issue.CommentSeq = 1
116+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
117+ // The issue moved on: the same version is refused.
118+ issue.CommentSeq = 2
119+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
120+ issue, err = is.Get(ctx, n, issue.Number)
121+ require.NoError(t, err)
122+ issue.CommentSeq = 2
123+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
124+
125+ comments, err := is.ListComments(ctx, n, issue.Number)
126+ require.NoError(t, err)
127+ require.Len(t, comments, 2)
128+ assert.Equal(t, "first", comments[0].Text.String())
129+ assert.Equal(t, int64(2), comments[1].ID)
130+
131+ require.NoError(t, s.Delete(ctx, n))
132+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
133+}
134+
135+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
136+ ctx := context.Background()
137+ s, mrs, n := repoWithMRs(t)
138+ is := s.Issues()
139+ const workers = 16
140+ var (
141+ wg sync.WaitGroup
142+ mu sync.Mutex
143+ numbers []int64
144+ )
145+ for w := range workers {
146+ wg.Go(func() {
147+ var number int64
148+ if w%2 == 0 {
149+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
150+ if !assert.NoError(t, err) {
151+ return
152+ }
153+ number = got.Number
154+ } else {
155+ got, err := is.Create(ctx, newIssue(t, n, "t"))
156+ if !assert.NoError(t, err) {
157+ return
158+ }
159+ number = got.Number
160+ }
161+ mu.Lock()
162+ numbers = append(numbers, number)
163+ mu.Unlock()
164+ })
165+ }
166+ wg.Wait()
167+ want := make([]int64, workers)
168+ for i := range want {
169+ want[i] = int64(i + 1)
170+ }
171+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
172+}
173+
174+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
175+ ctx := context.Background()
176+ s, _, n := repoWithMRs(t)
177+ sib := name(t, "proj-2")
178+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
179+ is := s.Issues()
180+
181+ for _, repo := range []domain.RepoName{n, sib} {
182+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
183+ require.NoError(t, err)
184+ issue.CommentSeq = 1
185+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
186+ }
187+ require.NoError(t, s.Delete(ctx, n))
188+
189+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
190+ require.NoError(t, err)
191+ assert.Empty(t, page.Issues)
192+ comments, err := is.ListComments(ctx, n, 1)
193+ require.NoError(t, err)
194+ assert.Empty(t, comments)
195+ assert.Empty(t, openOf(t, is, n))
196+ assert.Zero(t, countKeys(t, s, "is/proj/"))
197+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
198+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
199+
200+ open := openOf(t, is, sib)
201+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
202+ comments, err = is.ListComments(ctx, sib, 1)
203+ require.NoError(t, err)
204+ assert.Len(t, comments, 1)
205+
206+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
207+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
208+ require.NoError(t, err)
209+ assert.Equal(t, int64(1), again.Number)
210+}
211+
212+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
213+ ctx := context.Background()
214+ s, _, n := repoWithMRs(t)
215+ is := s.Issues()
216+ a, err := is.Create(ctx, newIssue(t, n, "a"))
217+ require.NoError(t, err)
218+ b, err := is.Create(ctx, newIssue(t, n, "b"))
219+ require.NoError(t, err)
220+
221+ numbers := func() []int64 {
222+ open := openOf(t, is, n)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
271+// original scope keep their owner rights, gaining mr:review and issue:write
272+// (each only if missing); no other token changes.
273+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
274+ ctx := context.Background()
275+ dir := t.TempDir()
276+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
277+
278+ all9 := []string{
279+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
280+ "ci:write", "secrets:write", "runner:register", "token:admin",
281+ }
282+ tokens := map[string]*storagepb.Token{
283+ "full": {Id: "full", Scopes: all9},
284+ "full10": {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
285+ "reposmr": {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
286+ "missing": {Id: "missing", Scopes: all9[:8]},
287+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
288+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
289+ "small": {Id: "small", Scopes: []string{"repo:read"}},
290+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
291+ }
292+ for id, tok := range tokens {
293+ tok.Hash = make([]byte, 32)
294+ tok.Hash[0] = byte(len(id))
295+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
296+ }
297+
298+ s, err := kvstore.Open(ctx, dir)
299+ require.NoError(t, err)
300+ t.Cleanup(func() { _ = s.Close(ctx) })
301+ get := func(id string) domain.Token {
302+ tok, err := s.Tokens().Get(ctx, id)
303+ require.NoError(t, err)
304+ return tok
305+ }
306+
307+ full := get("full")
308+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
309+ assert.Contains(t, full.Scopes, domain.ScopeMRReview)
310+ assert.Len(t, full.Scopes, 11)
311+ full10 := get("full10")
312+ assert.Len(t, full10.Scopes, 11)
313+ assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
314+ assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
315+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
316+ assert.Len(t, get("reposmr").Scopes, 10)
317+ assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
318+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
319+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
320+
321+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
322+ assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
323+ assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
324+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
325+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
326+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
327+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
328+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
329+ assert.Len(t, get("missing").Scopes, 8)
330+ assert.Len(t, get("repos").Scopes, 9)
331+ assert.Len(t, get("prefix").Scopes, 9)
332+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
333+ assert.Equal(t, "full", full.ID)
334+
335+ require.NoError(t, s.Close(ctx))
336+ assert.Equal(t, uint32(4), readSchema(t, dir))
337+
338+ // A second open changes nothing.
339+ s2, err := kvstore.Open(ctx, dir)
340+ require.NoError(t, err)
341+ tok, err := s2.Tokens().Get(ctx, "full")
342+ require.NoError(t, err)
343+ assert.Equal(t, full.Scopes, tok.Scopes)
344+ tok10, err := s2.Tokens().Get(ctx, "full10")
345+ require.NoError(t, err)
346+ assert.Equal(t, full10.Scopes, tok10.Scopes)
347+ require.NoError(t, s2.Close(ctx))
348+}
349+
350+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
351+ ctx := context.Background()
352+ dir := t.TempDir()
353+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
354+ _, err := kvstore.Open(ctx, dir)
355+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
356+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
357+}
358+
359+// A reopen racing the delete of the repository either lands before the delete
360+// removes it or fails; it never leaves an isopen/ key of a repository that is
361+// gone.
362+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
363+ ctx := context.Background()
364+ for range 30 {
365+ s, _, n := repoWithMRs(t)
366+ is := s.Issues()
367+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
368+ require.NoError(t, err)
369+ issue.State = domain.IssueStateClosed
370+ require.NoError(t, is.Update(ctx, issue))
371+ closed, err := is.Get(ctx, n, issue.Number)
372+ require.NoError(t, err)
373+ closed.State = domain.IssueStateOpen
374+
375+ var wg sync.WaitGroup
376+ start := make(chan struct{})
377+ wg.Add(2)
378+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
379+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
380+ close(start)
381+ wg.Wait()
382+
383+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
384+ assert.Zero(t, countKeys(t, s, prefix), prefix)
385+ }
386+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
387+ }
388+}
389+
390+func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
391+ ctx := context.Background()
392+ s, mrs, n := repoWithMRs(t)
393+ other := name(t, "other")
394+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
395+ is := s.Issues()
396+
397+ i1, err := is.Create(ctx, newIssue(t, n, "one"))
398+ require.NoError(t, err)
399+ i2, err := is.Create(ctx, newIssue(t, other, "two"))
400+ require.NoError(t, err)
401+ i3, err := is.Create(ctx, newIssue(t, other, "three"))
402+ require.NoError(t, err)
403+ i3.State = domain.IssueStateClosed
404+ require.NoError(t, is.Update(ctx, i3))
405+ _, err = mrs.Create(ctx, newMR(t, n, "mr1"))
406+ require.NoError(t, err)
407+ m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
408+ require.NoError(t, err)
409+ m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
410+ require.NoError(t, err)
411+ m3.State = domain.MRStateClosed
412+ require.NoError(t, mrs.Update(ctx, m3))
413+
414+ issues, err := is.ListOpenAll(ctx)
415+ require.NoError(t, err)
416+ var got []string
417+ for _, i := range issues {
418+ got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
419+ }
420+ assert.ElementsMatch(t, []string{
421+ i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
422+ i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
423+ }, got)
424+
425+ open, err := mrs.ListOpenAll(ctx)
426+ require.NoError(t, err)
427+ got = nil
428+ for _, m := range open {
429+ got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
430+ }
431+ assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
432+ assert.Len(t, open, 2)
433+}
434+
435+func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
436+ ctx := context.Background()
437+ s, mrs, _ := repoWithMRs(t)
438+ issues, err := s.Issues().ListOpenAll(ctx)
439+ require.NoError(t, err)
440+ assert.Empty(t, issues)
441+ open, err := mrs.ListOpenAll(ctx)
442+ require.NoError(t, err)
443+ assert.Empty(t, open)
444+}
445+
446+func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
447+ ctx := context.Background()
448+ s, mrs, n := repoWithMRs(t)
449+ is := s.Issues()
450+ keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
451+ require.NoError(t, err)
452+ goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
453+ require.NoError(t, err)
454+ keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
455+ require.NoError(t, err)
456+ goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
457+ require.NoError(t, err)
458+
459+ require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
460+ require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
461+ require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
462+ require.Positive(t, countKeys(t, s, "mropen/proj/"))
463+
464+ issues, err := is.ListOpenAll(ctx)
465+ require.NoError(t, err)
466+ require.Len(t, issues, 1)
467+ assert.Equal(t, keepI.Number, issues[0].Number)
468+ open, err := mrs.ListOpenAll(ctx)
469+ require.NoError(t, err)
470+ require.Len(t, open, 1)
471+ assert.Equal(t, keepM.Number, open[0].Number)
472+}
473+
474+func TestIssueListPagesThroughManyIssues(t *testing.T) {
475+ ctx := context.Background()
476+ s, _, n := repoWithMRs(t)
477+ is := s.Issues()
478+ const total = 53
479+ for i := 1; i <= total; i++ {
480+ _, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
481+ require.NoError(t, err)
482+ }
483+ for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
484+ is, err := is.Get(ctx, n, int64(i))
485+ require.NoError(t, err)
486+ is.State = domain.IssueStateClosed
487+ require.NoError(t, s.Issues().Update(ctx, is))
488+ }
489+
490+ walk := func(state domain.IssueState, size int) []int64 {
491+ var got []int64
492+ token := ""
493+ for {
494+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
495+ require.NoError(t, err)
496+ require.LessOrEqual(t, len(page.Issues), size)
497+ for _, is := range page.Issues {
498+ got = append(got, is.Number)
499+ }
500+ if page.NextPageToken == "" {
501+ return got
502+ }
503+ token = page.NextPageToken
504+ }
505+ }
506+ for _, size := range []int{1, 7, 10, 53, 100} {
507+ var all, closed []int64
508+ for i := int64(total); i >= 1; i-- {
509+ all = append(all, i)
510+ if i%3 == 2 {
511+ closed = append(closed, i)
512+ }
513+ }
514+ assert.Equal(t, all, walk("", size), "size %d", size)
515+ assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
516+ }
517+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
518+ require.NoError(t, err)
519+ assert.Empty(t, page.Issues)
520+ assert.Empty(t, page.NextPageToken)
521+}
522+
523+// openOf lists the open issues of one repository through the isopen/ index.
524+func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
525+ t.Helper()
526+ all, err := is.ListOpenAll(context.Background())
527+ require.NoError(t, err)
528+ var out []domain.Issue
529+ for _, i := range all {
530+ if i.Repo == repo {
531+ out = append(out, i)
532+ }
533+ }
534+ sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
535+ return out
536+}
537+
538+func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
539+ ctx := context.Background()
540+ s, _, n := repoWithMRs(t)
541+ is := s.Issues()
542+ raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
543+ require.NoError(t, err)
544+ require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
545+ _, err = is.Get(ctx, n, 1)
546+ assert.ErrorContains(t, err, "pending")
547+ _, err = is.List(ctx, n, ports.ListIssuesParams{})
548+ assert.ErrorContains(t, err, "pending")
549+ _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
550+ assert.ErrorContains(t, err, "pending")
551+}
552+
553+func countScope(scopes []domain.Scope, want domain.Scope) int {
554+ n := 0
555+ for _, s := range scopes {
556+ if s == want {
557+ n++
558+ }
559+ }
560+ return n
561+}
@@ -0,0 +1,561 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "slices"
8+ "sort"
9+ "strconv"
10+ "sync"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/adapters/kvstore"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+func newIssue(t *testing.T, repo domain.RepoName, title string) domain.Issue {
24+ t.Helper()
25+ ti, err := domain.ParseIssueTitle(title)
26+ require.NoError(t, err)
27+ body, err := domain.ParseIssueBody("what\nhappened")
28+ require.NoError(t, err)
29+ return domain.Issue{
30+ Repo: repo, Title: ti, Body: body, State: domain.IssueStateOpen,
31+ Author: "agent", CreatedAt: created, UpdatedAt: created,
32+ }
33+}
34+
35+func newIssueComment(t *testing.T, id int64, text string) domain.IssueComment {
36+ t.Helper()
37+ body, err := domain.ParseIssueBody(text)
38+ require.NoError(t, err)
39+ return domain.IssueComment{ID: id, Author: "agent", Text: body, CreatedAt: created}
40+}
41+
42+// countKeys counts the keys of the store under prefix.
43+func countKeys(t *testing.T, s *kvstore.Store, prefix string) int {
44+ t.Helper()
45+ return s.CountKeysForTest(context.Background(), prefix)
46+}
47+
48+func TestIssueRoundTripAndPaging(t *testing.T) {
49+ ctx := context.Background()
50+ s, _, n := repoWithMRs(t)
51+ is := s.Issues()
52+
53+ first, err := is.Create(ctx, newIssue(t, n, "one"))
54+ require.NoError(t, err)
55+ assert.Equal(t, int64(1), first.Number)
56+ assert.NotZero(t, first.Version)
57+ got, err := is.Get(ctx, n, 1)
58+ require.NoError(t, err)
59+ assert.Equal(t, first, got)
60+ _, err = is.Get(ctx, n, 2)
61+ assert.ErrorIs(t, err, domain.ErrNotFound)
62+
63+ for _, title := range []string{"two", "three", "four"} {
64+ _, err := is.Create(ctx, newIssue(t, n, title))
65+ require.NoError(t, err)
66+ }
67+ closed, err := is.Get(ctx, n, 2)
68+ require.NoError(t, err)
69+ closed.State = domain.IssueStateClosed
70+ require.NoError(t, is.Update(ctx, closed))
71+
72+ page, err := is.List(ctx, n, ports.ListIssuesParams{PageSize: 3})
73+ require.NoError(t, err)
74+ require.Len(t, page.Issues, 3)
75+ assert.Equal(t, int64(4), page.Issues[0].Number)
76+ require.NotEmpty(t, page.NextPageToken)
77+ page, err = is.List(ctx, n, ports.ListIssuesParams{PageSize: 3, PageToken: page.NextPageToken})
78+ require.NoError(t, err)
79+ require.Len(t, page.Issues, 1)
80+ assert.Equal(t, int64(1), page.Issues[0].Number)
81+ assert.Empty(t, page.NextPageToken)
82+
83+ page, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateClosed})
84+ require.NoError(t, err)
85+ require.Len(t, page.Issues, 1)
86+ assert.Equal(t, int64(2), page.Issues[0].Number)
87+
88+ _, err = is.List(ctx, n, ports.ListIssuesParams{PageToken: "x"})
89+ assert.ErrorIs(t, err, domain.ErrInvalid)
90+
91+ _, err = is.Create(ctx, newIssue(t, name(t, "ghost"), "x"))
92+ assert.ErrorIs(t, err, domain.ErrNotFound)
93+}
94+
95+func TestIssueUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
96+ ctx := context.Background()
97+ s, _, n := repoWithMRs(t)
98+ is := s.Issues()
99+ first, err := is.Create(ctx, newIssue(t, n, "t"))
100+ require.NoError(t, err)
101+ a, b := first, first
102+ a.State = domain.IssueStateClosed
103+ require.NoError(t, is.Update(ctx, a))
104+ b.State = domain.IssueStateClosed
105+ assert.ErrorIs(t, is.Update(ctx, b), domain.ErrConflict)
106+}
107+
108+func TestIssueCommentsAndSaveCommentPreconditions(t *testing.T) {
109+ ctx := context.Background()
110+ s, _, n := repoWithMRs(t)
111+ is := s.Issues()
112+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
113+ require.NoError(t, err)
114+
115+ issue.CommentSeq = 1
116+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "first")))
117+ // The issue moved on: the same version is refused.
118+ issue.CommentSeq = 2
119+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")), domain.ErrConflict)
120+ issue, err = is.Get(ctx, n, issue.Number)
121+ require.NoError(t, err)
122+ issue.CommentSeq = 2
123+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 2, "second")))
124+
125+ comments, err := is.ListComments(ctx, n, issue.Number)
126+ require.NoError(t, err)
127+ require.Len(t, comments, 2)
128+ assert.Equal(t, "first", comments[0].Text.String())
129+ assert.Equal(t, int64(2), comments[1].ID)
130+
131+ require.NoError(t, s.Delete(ctx, n))
132+ assert.ErrorIs(t, is.SaveComment(ctx, issue, newIssueComment(t, 3, "late")), domain.ErrNotFound)
133+}
134+
135+func TestConcurrentMergeRequestsAndIssuesNeverShareANumber(t *testing.T) {
136+ ctx := context.Background()
137+ s, mrs, n := repoWithMRs(t)
138+ is := s.Issues()
139+ const workers = 16
140+ var (
141+ wg sync.WaitGroup
142+ mu sync.Mutex
143+ numbers []int64
144+ )
145+ for w := range workers {
146+ wg.Go(func() {
147+ var number int64
148+ if w%2 == 0 {
149+ got, err := mrs.Create(ctx, newMR(t, n, "t"))
150+ if !assert.NoError(t, err) {
151+ return
152+ }
153+ number = got.Number
154+ } else {
155+ got, err := is.Create(ctx, newIssue(t, n, "t"))
156+ if !assert.NoError(t, err) {
157+ return
158+ }
159+ number = got.Number
160+ }
161+ mu.Lock()
162+ numbers = append(numbers, number)
163+ mu.Unlock()
164+ })
165+ }
166+ wg.Wait()
167+ want := make([]int64, workers)
168+ for i := range want {
169+ want[i] = int64(i + 1)
170+ }
171+ assert.ElementsMatch(t, want, numbers, "every number is used once, by an issue or a merge request")
172+}
173+
174+func TestDeleteDropsTheIssuesOfTheRepository(t *testing.T) {
175+ ctx := context.Background()
176+ s, _, n := repoWithMRs(t)
177+ sib := name(t, "proj-2")
178+ require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
179+ is := s.Issues()
180+
181+ for _, repo := range []domain.RepoName{n, sib} {
182+ issue, err := is.Create(ctx, newIssue(t, repo, "t"))
183+ require.NoError(t, err)
184+ issue.CommentSeq = 1
185+ require.NoError(t, is.SaveComment(ctx, issue, newIssueComment(t, 1, "note")))
186+ }
187+ require.NoError(t, s.Delete(ctx, n))
188+
189+ page, err := is.List(ctx, n, ports.ListIssuesParams{})
190+ require.NoError(t, err)
191+ assert.Empty(t, page.Issues)
192+ comments, err := is.ListComments(ctx, n, 1)
193+ require.NoError(t, err)
194+ assert.Empty(t, comments)
195+ assert.Empty(t, openOf(t, is, n))
196+ assert.Zero(t, countKeys(t, s, "is/proj/"))
197+ assert.Zero(t, countKeys(t, s, "isc/proj/"))
198+ assert.Zero(t, countKeys(t, s, "isopen/proj/"))
199+
200+ open := openOf(t, is, sib)
201+ assert.Len(t, open, 1, "a repository whose name starts like the deleted one keeps its issues")
202+ comments, err = is.ListComments(ctx, sib, 1)
203+ require.NoError(t, err)
204+ assert.Len(t, comments, 1)
205+
206+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
207+ again, err := is.Create(ctx, newIssue(t, n, "fresh"))
208+ require.NoError(t, err)
209+ assert.Equal(t, int64(1), again.Number)
210+}
211+
212+func TestOpenIssueIndexFollowsUpdates(t *testing.T) {
213+ ctx := context.Background()
214+ s, _, n := repoWithMRs(t)
215+ is := s.Issues()
216+ a, err := is.Create(ctx, newIssue(t, n, "a"))
217+ require.NoError(t, err)
218+ b, err := is.Create(ctx, newIssue(t, n, "b"))
219+ require.NoError(t, err)
220+
221+ numbers := func() []int64 {
222+ open := openOf(t, is, n)
223+ var out []int64
224+ for _, i := range open {
225+ out = append(out, i.Number)
226+ }
227+ return out
228+ }
229+ assert.Equal(t, []int64{2, 1}, numbers())
230+ assert.Equal(t, 2, countKeys(t, s, "isopen/proj/"))
231+
232+ a.State = domain.IssueStateClosed
233+ require.NoError(t, is.Update(ctx, a))
234+ assert.Equal(t, []int64{2}, numbers())
235+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
236+
237+ a, err = is.Get(ctx, n, a.Number)
238+ require.NoError(t, err)
239+ a.State = domain.IssueStateOpen
240+ require.NoError(t, is.Update(ctx, a))
241+ assert.Equal(t, []int64{2, 1}, numbers())
242+
243+ // A comment keeps the index as the issue says.
244+ b.State = domain.IssueStateClosed
245+ b.CommentSeq = 1
246+ require.NoError(t, is.SaveComment(ctx, b, newIssueComment(t, 1, "closing")))
247+ assert.Equal(t, []int64{1}, numbers())
248+ assert.Equal(t, 1, countKeys(t, s, "isopen/proj/"))
249+}
250+
251+func TestRepoSettingsKeepTheIssuesMode(t *testing.T) {
252+ ctx := context.Background()
253+ s := openStore(t, t.TempDir())
254+ n := name(t, "proj")
255+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
256+ got, err := s.Get(ctx, n)
257+ require.NoError(t, err)
258+ assert.Equal(t, domain.IssuesOff, got.Settings.Issues)
259+ for _, m := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesPublic, domain.IssuesOff} {
260+ got.Settings.Issues = m
261+ require.NoError(t, s.Update(ctx, got))
262+ again, err := s.Get(ctx, n)
263+ require.NoError(t, err)
264+ assert.Equal(t, m, again.Settings.Issues)
265+ }
266+ got.Settings.Issues = "everyone"
267+ assert.ErrorIs(t, s.Update(ctx, got), domain.ErrInvalid)
268+}
269+
270+// TestSchema4UpgradesTheTokensWithAllScopes: the tokens that held every
271+// original scope keep their owner rights, gaining mr:review and issue:write
272+// (each only if missing); no other token changes.
273+func TestSchema4UpgradesTheTokensWithAllScopes(t *testing.T) {
274+ ctx := context.Background()
275+ dir := t.TempDir()
276+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
277+
278+ all9 := []string{
279+ "repo:read", "repo:write", "repo:admin", "mr:write", "mr:merge",
280+ "ci:write", "secrets:write", "runner:register", "token:admin",
281+ }
282+ tokens := map[string]*storagepb.Token{
283+ "full": {Id: "full", Scopes: all9},
284+ "full10": {Id: "full10", Scopes: append(slices.Clone(all9), "mr:review")},
285+ "reposmr": {Id: "reposmr", Scopes: append(slices.Clone(all9), "mr:review"), Repos: []string{"proj"}},
286+ "missing": {Id: "missing", Scopes: all9[:8]},
287+ "repos": {Id: "repos", Scopes: all9, Repos: []string{"proj"}},
288+ "prefix": {Id: "prefix", Scopes: all9, BranchPrefixes: []string{"agent/"}},
289+ "small": {Id: "small", Scopes: []string{"repo:read"}},
290+ "otherfull": {Id: "otherfull", Scopes: append([]string{"token:admin"}, all9[:8]...)},
291+ }
292+ for id, tok := range tokens {
293+ tok.Hash = make([]byte, 32)
294+ tok.Hash[0] = byte(len(id))
295+ putRaw(t, dir, "token/"+id, mustProto(t, tok))
296+ }
297+
298+ s, err := kvstore.Open(ctx, dir)
299+ require.NoError(t, err)
300+ t.Cleanup(func() { _ = s.Close(ctx) })
301+ get := func(id string) domain.Token {
302+ tok, err := s.Tokens().Get(ctx, id)
303+ require.NoError(t, err)
304+ return tok
305+ }
306+
307+ full := get("full")
308+ assert.Contains(t, full.Scopes, domain.ScopeIssueWrite)
309+ assert.Contains(t, full.Scopes, domain.ScopeMRReview)
310+ assert.Len(t, full.Scopes, 11)
311+ full10 := get("full10")
312+ assert.Len(t, full10.Scopes, 11)
313+ assert.Equal(t, 1, countScope(full10.Scopes, domain.ScopeMRReview))
314+ assert.Contains(t, full10.Scopes, domain.ScopeIssueWrite)
315+ assert.True(t, domain.TokenPrincipal(full10).HasOwnerRights())
316+ assert.Len(t, get("reposmr").Scopes, 10)
317+ assert.NotContains(t, get("reposmr").Scopes, domain.ScopeIssueWrite)
318+ assert.True(t, domain.TokenPrincipal(full).HasOwnerRights())
319+ assert.True(t, domain.TokenPrincipal(get("otherfull")).HasOwnerRights(), "the order of the scopes does not matter")
320+
321+ assert.NotContains(t, get("missing").Scopes, domain.ScopeIssueWrite)
322+ assert.NotContains(t, get("missing").Scopes, domain.ScopeMRReview)
323+ assert.NotContains(t, get("repos").Scopes, domain.ScopeMRReview)
324+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeMRReview)
325+ assert.False(t, domain.TokenPrincipal(get("missing")).HasOwnerRights())
326+ assert.NotContains(t, get("repos").Scopes, domain.ScopeIssueWrite)
327+ assert.NotContains(t, get("prefix").Scopes, domain.ScopeIssueWrite)
328+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, get("small").Scopes)
329+ assert.Len(t, get("missing").Scopes, 8)
330+ assert.Len(t, get("repos").Scopes, 9)
331+ assert.Len(t, get("prefix").Scopes, 9)
332+ assert.Equal(t, []string{"agent/"}, get("prefix").BranchPrefixes)
333+ assert.Equal(t, "full", full.ID)
334+
335+ require.NoError(t, s.Close(ctx))
336+ assert.Equal(t, uint32(4), readSchema(t, dir))
337+
338+ // A second open changes nothing.
339+ s2, err := kvstore.Open(ctx, dir)
340+ require.NoError(t, err)
341+ tok, err := s2.Tokens().Get(ctx, "full")
342+ require.NoError(t, err)
343+ assert.Equal(t, full.Scopes, tok.Scopes)
344+ tok10, err := s2.Tokens().Get(ctx, "full10")
345+ require.NoError(t, err)
346+ assert.Equal(t, full10.Scopes, tok10.Scopes)
347+ require.NoError(t, s2.Close(ctx))
348+}
349+
350+func TestSchemaNewerThanThisBuildStopsIssuesData(t *testing.T) {
351+ ctx := context.Background()
352+ dir := t.TempDir()
353+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: kvstore.SchemaVersion + 1}))
354+ _, err := kvstore.Open(ctx, dir)
355+ require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
356+ assert.Equal(t, uint32(4), uint32(kvstore.SchemaVersion))
357+}
358+
359+// A reopen racing the delete of the repository either lands before the delete
360+// removes it or fails; it never leaves an isopen/ key of a repository that is
361+// gone.
362+func TestDeleteVersusReopenLeavesNoIssueKeys(t *testing.T) {
363+ ctx := context.Background()
364+ for range 30 {
365+ s, _, n := repoWithMRs(t)
366+ is := s.Issues()
367+ issue, err := is.Create(ctx, newIssue(t, n, "t"))
368+ require.NoError(t, err)
369+ issue.State = domain.IssueStateClosed
370+ require.NoError(t, is.Update(ctx, issue))
371+ closed, err := is.Get(ctx, n, issue.Number)
372+ require.NoError(t, err)
373+ closed.State = domain.IssueStateOpen
374+
375+ var wg sync.WaitGroup
376+ start := make(chan struct{})
377+ wg.Add(2)
378+ go func() { defer wg.Done(); <-start; _ = is.Update(ctx, closed) }()
379+ go func() { defer wg.Done(); <-start; _ = s.Delete(ctx, n) }()
380+ close(start)
381+ wg.Wait()
382+
383+ for _, prefix := range []string{"is/proj/", "isc/proj/", "isopen/proj/"} {
384+ assert.Zero(t, countKeys(t, s, prefix), prefix)
385+ }
386+ assert.ErrorIs(t, is.Update(ctx, closed), domain.ErrNotFound)
387+ }
388+}
389+
390+func TestListOpenAllSpansRepositoriesThroughTheIndex(t *testing.T) {
391+ ctx := context.Background()
392+ s, mrs, n := repoWithMRs(t)
393+ other := name(t, "other")
394+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
395+ is := s.Issues()
396+
397+ i1, err := is.Create(ctx, newIssue(t, n, "one"))
398+ require.NoError(t, err)
399+ i2, err := is.Create(ctx, newIssue(t, other, "two"))
400+ require.NoError(t, err)
401+ i3, err := is.Create(ctx, newIssue(t, other, "three"))
402+ require.NoError(t, err)
403+ i3.State = domain.IssueStateClosed
404+ require.NoError(t, is.Update(ctx, i3))
405+ _, err = mrs.Create(ctx, newMR(t, n, "mr1"))
406+ require.NoError(t, err)
407+ m2, err := mrs.Create(ctx, newMR(t, other, "mr2"))
408+ require.NoError(t, err)
409+ m3, err := mrs.Create(ctx, newMR(t, other, "mr3"))
410+ require.NoError(t, err)
411+ m3.State = domain.MRStateClosed
412+ require.NoError(t, mrs.Update(ctx, m3))
413+
414+ issues, err := is.ListOpenAll(ctx)
415+ require.NoError(t, err)
416+ var got []string
417+ for _, i := range issues {
418+ got = append(got, i.Repo.String()+"#"+strconv.FormatInt(i.Number, 10))
419+ }
420+ assert.ElementsMatch(t, []string{
421+ i1.Repo.String() + "#" + strconv.FormatInt(i1.Number, 10),
422+ i2.Repo.String() + "#" + strconv.FormatInt(i2.Number, 10),
423+ }, got)
424+
425+ open, err := mrs.ListOpenAll(ctx)
426+ require.NoError(t, err)
427+ got = nil
428+ for _, m := range open {
429+ got = append(got, m.Repo.String()+"#"+strconv.FormatInt(m.Number, 10))
430+ }
431+ assert.ElementsMatch(t, []string{"proj#2", m2.Repo.String() + "#" + strconv.FormatInt(m2.Number, 10)}, got)
432+ assert.Len(t, open, 2)
433+}
434+
435+func TestListOpenAllIsEmptyWithoutOpenItems(t *testing.T) {
436+ ctx := context.Background()
437+ s, mrs, _ := repoWithMRs(t)
438+ issues, err := s.Issues().ListOpenAll(ctx)
439+ require.NoError(t, err)
440+ assert.Empty(t, issues)
441+ open, err := mrs.ListOpenAll(ctx)
442+ require.NoError(t, err)
443+ assert.Empty(t, open)
444+}
445+
446+func TestListOpenAllSkipsAStaleIndexEntry(t *testing.T) {
447+ ctx := context.Background()
448+ s, mrs, n := repoWithMRs(t)
449+ is := s.Issues()
450+ keepI, err := is.Create(ctx, newIssue(t, n, "keep"))
451+ require.NoError(t, err)
452+ goneI, err := is.Create(ctx, newIssue(t, n, "gone"))
453+ require.NoError(t, err)
454+ keepM, err := mrs.Create(ctx, newMR(t, n, "keep"))
455+ require.NoError(t, err)
456+ goneM, err := mrs.Create(ctx, newMR(t, n, "gone"))
457+ require.NoError(t, err)
458+
459+ require.NoError(t, is.DropIssueRecordForTest(ctx, n, goneI.Number))
460+ require.NoError(t, mrs.DropMRRecordForTest(ctx, n, goneM.Number))
461+ require.Positive(t, countKeys(t, s, "isopen/proj/"), "the index entries are still there")
462+ require.Positive(t, countKeys(t, s, "mropen/proj/"))
463+
464+ issues, err := is.ListOpenAll(ctx)
465+ require.NoError(t, err)
466+ require.Len(t, issues, 1)
467+ assert.Equal(t, keepI.Number, issues[0].Number)
468+ open, err := mrs.ListOpenAll(ctx)
469+ require.NoError(t, err)
470+ require.Len(t, open, 1)
471+ assert.Equal(t, keepM.Number, open[0].Number)
472+}
473+
474+func TestIssueListPagesThroughManyIssues(t *testing.T) {
475+ ctx := context.Background()
476+ s, _, n := repoWithMRs(t)
477+ is := s.Issues()
478+ const total = 53
479+ for i := 1; i <= total; i++ {
480+ _, err := is.Create(ctx, newIssue(t, n, "issue "+strconv.Itoa(i)))
481+ require.NoError(t, err)
482+ }
483+ for i := 2; i <= total; i += 3 { // 2, 5, 8, ...
484+ is, err := is.Get(ctx, n, int64(i))
485+ require.NoError(t, err)
486+ is.State = domain.IssueStateClosed
487+ require.NoError(t, s.Issues().Update(ctx, is))
488+ }
489+
490+ walk := func(state domain.IssueState, size int) []int64 {
491+ var got []int64
492+ token := ""
493+ for {
494+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{State: state, PageSize: size, PageToken: token})
495+ require.NoError(t, err)
496+ require.LessOrEqual(t, len(page.Issues), size)
497+ for _, is := range page.Issues {
498+ got = append(got, is.Number)
499+ }
500+ if page.NextPageToken == "" {
501+ return got
502+ }
503+ token = page.NextPageToken
504+ }
505+ }
506+ for _, size := range []int{1, 7, 10, 53, 100} {
507+ var all, closed []int64
508+ for i := int64(total); i >= 1; i-- {
509+ all = append(all, i)
510+ if i%3 == 2 {
511+ closed = append(closed, i)
512+ }
513+ }
514+ assert.Equal(t, all, walk("", size), "size %d", size)
515+ assert.Equal(t, closed, walk(domain.IssueStateClosed, size), "closed, size %d", size)
516+ }
517+ page, err := s.Issues().List(ctx, n, ports.ListIssuesParams{PageSize: 10, PageToken: "1"})
518+ require.NoError(t, err)
519+ assert.Empty(t, page.Issues)
520+ assert.Empty(t, page.NextPageToken)
521+}
522+
523+// openOf lists the open issues of one repository through the isopen/ index.
524+func openOf(t *testing.T, is *kvstore.Issues, repo domain.RepoName) []domain.Issue {
525+ t.Helper()
526+ all, err := is.ListOpenAll(context.Background())
527+ require.NoError(t, err)
528+ var out []domain.Issue
529+ for _, i := range all {
530+ if i.Repo == repo {
531+ out = append(out, i)
532+ }
533+ }
534+ sort.Slice(out, func(a, b int) bool { return out[a].Number > out[b].Number })
535+ return out
536+}
537+
538+func TestStoredPendingIssueStateIsCorrupt(t *testing.T) {
539+ ctx := context.Background()
540+ s, _, n := repoWithMRs(t)
541+ is := s.Issues()
542+ raw, err := proto.Marshal(&storagepb.Issue{Number: 1, Title: "x", State: storagepb.IssueState_ISSUE_STATE_PENDING})
543+ require.NoError(t, err)
544+ require.NoError(t, is.PutRawIssueForTest(ctx, n, 1, raw))
545+ _, err = is.Get(ctx, n, 1)
546+ assert.ErrorContains(t, err, "pending")
547+ _, err = is.List(ctx, n, ports.ListIssuesParams{})
548+ assert.ErrorContains(t, err, "pending")
549+ _, err = is.List(ctx, n, ports.ListIssuesParams{State: domain.IssueStateOpen})
550+ assert.ErrorContains(t, err, "pending")
551+}
552+
553+func countScope(scopes []domain.Scope, want domain.Scope) int {
554+ n := 0
555+ for _, s := range scopes {
556+ if s == want {
557+ n++
558+ }
559+ }
560+ return n
561+}
M internal/adapters/kvstore/mergerequests.go +123 -26
@@ -6,10 +6,12 @@ import (
66 "context"
77 "errors"
88 "fmt"
9+ "hash/fnv"
910 "net/url"
1011 "slices"
1112 "strconv"
1213 "strings"
14+ "sync"
1315 "unicode/utf8"
1416
1517 kv "bnema.dev/ermite-kv"
@@ -44,14 +46,15 @@ const (
4446 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
4547 // database of the Store it comes from.
4648 type MergeRequests struct {
47- db *kv.DB
49+ db *kv.DB
50+ locks *numberLocks
4851 }
4952
5053 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
5154
5255 // MergeRequests returns the merge request store over the same database. It is
5356 // valid until the Store is closed.
54-func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} }
57+func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db, locks: s.locks} }
5558
5659 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
5760 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -198,47 +201,90 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198201 //
199202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200203 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
204+ err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
205+ mr.Number = n
206+ val, err := encodeMergeRequest(mr)
207+ if err != nil {
208+ return err
209+ }
210+ b.RequireAbsent(mrKey(mr.Repo, n))
211+ b.Put(mrKey(mr.Repo, n), val)
212+ indexOpen(b, nil, mr)
213+ return nil
214+ })
215+ if err != nil {
216+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
217+ }
218+ return m.Get(ctx, mr.Repo, mr.Number)
219+}
220+
221+// createNumbered takes the next number of the repository from mrseq/<repo>,
222+// shared by the merge requests and the issues, and lets add put the record
223+// under it into a batch that requires the sequence to be unchanged since it was
224+// read (absent for the first number) and the repository to be unchanged (see
225+// guardRepoKey): two concurrent creations, of either kind, never share a
226+// number. It retries when the batch conflicts; a missing repository gives
227+// domain.ErrNotFound.
228+//
229+// Creations in one repository are serialized in the process (numberLocks), so
230+// a burst of merge requests and issues never exhausts the retries; the version
231+// checks still guard against any other writer.
232+func createNumbered(ctx context.Context, db *kv.DB, locks *numberLocks, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
233+ unlock := locks.lock(repo)
234+ defer unlock()
201235 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))
236+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203237 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound
238+ return domain.ErrNotFound
205239 }
206240 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))
241+ item, ok := db.Get(ctx, seqKey(repo))
208242 if ok {
209243 var seq storagepb.Sequence
210244 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)
245+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212246 }
213247 last = seq.GetLast()
214248 }
215- mr.Number = last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
249+ number := last + 1
220250 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo
251+ b.RequireVersion(repoKey(repo), repoItem.Version)
252+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223253 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)
254+ b.RequireVersion(seqKey(repo), item.Version)
225255 } else {
226- b.RequireAbsent(seqKey(mr.Repo))
256+ b.RequireAbsent(seqKey(repo))
227257 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))
230- b.Put(mrKey(mr.Repo, mr.Number), val)
231- indexOpen(&b, nil, mr)
232- _, err = m.db.Apply(ctx, &b)
258+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
259+ if err := add(&b, number); err != nil {
260+ return err
261+ }
262+ _, err := db.Apply(ctx, &b)
233263 if errors.Is(err, kv.ErrConflict) {
234264 continue
235265 }
236- if err != nil {
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
266+ return err
240267 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)
268+ return domain.ErrConflict
269+}
270+
271+// numberLockStripes is the size of the table of numbering locks.
272+const numberLockStripes = 64
273+
274+// numberLocks is a fixed table of mutexes, one stripe per hash of the
275+// repository name, for createNumbered: the memory is bounded whatever the
276+// number of repositories, and two repositories sharing a stripe only wait for
277+// each other. One table is shared by every store of a Store, so the merge
278+// requests and the issues of a repository are numbered one at a time.
279+type numberLocks [numberLockStripes]sync.Mutex
280+
281+// lock locks the numbering of repo and returns the unlock function.
282+func (l *numberLocks) lock(repo domain.RepoName) func() {
283+ h := fnv.New32a()
284+ _, _ = h.Write([]byte(repo.String()))
285+ mu := &l[h.Sum32()%numberLockStripes]
286+ mu.Lock()
287+ return mu.Unlock
242288 }
243289
244290 // Get reads mr/<repo>/<number>.
@@ -344,6 +390,52 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344390 return out, nil
345391 }
346392
393+// ListOpenAll returns the open merge requests of every repository, repository
394+// by repository and highest number first, through the mropen/ index: the
395+// requests it names are read, no other.
396+//
397+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open merge requests of all repositories through their index: a storage concern, no policy
398+func (m *MergeRequests) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
399+ type ref struct {
400+ repo domain.RepoName
401+ n int64
402+ }
403+ var refs []ref
404+ for key := range m.db.Snapshot(ctx).Prefix(ctx, []byte(mrOpenPrefix)) {
405+ if err := ctx.Err(); err != nil {
406+ return nil, err
407+ }
408+ rest := strings.TrimPrefix(string(key), mrOpenPrefix)
409+ repoName, _, ok := strings.Cut(rest, "/")
410+ if !ok {
411+ return nil, fmt.Errorf("index key %q", key)
412+ }
413+ repo, err := domain.ParseRepoName(repoName)
414+ if err != nil {
415+ return nil, fmt.Errorf("index key %q: %w", key, err)
416+ }
417+ n, err := strconv.ParseInt(rest[strings.LastIndexByte(rest, '/')+1:], 10, 64)
418+ if err != nil {
419+ return nil, fmt.Errorf("index key %q: %w", key, err)
420+ }
421+ refs = append(refs, ref{repo, n})
422+ }
423+ var out []domain.MergeRequest
424+ for _, r := range refs {
425+ mr, err := m.Get(ctx, r.repo, r.n)
426+ if errors.Is(err, domain.ErrNotFound) {
427+ continue // closed or deleted since the snapshot of the index
428+ }
429+ if err != nil {
430+ return nil, err
431+ }
432+ if mr.IsOpen() {
433+ out = append(out, mr)
434+ }
435+ }
436+ return out, nil
437+}
438+
347439 // CountOpen counts the open merge requests of the repository from the keys of
348440 // the index: no request is decoded.
349441 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
@@ -689,6 +781,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
689781 }
690782 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
691783 }
784+ issues, err := encodeIssuesMode(s.Issues)
785+ if err != nil {
786+ return nil, err
787+ }
788+ pb.Issues = issues
692789 return pb, nil
693790 }
694791
@@ -713,5 +810,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
713810 if err != nil {
714811 return domain.DefaultRepoSettings()
715812 }
716- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}
813+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
717814 }
@@ -6,10 +6,12 @@ import (
6 "context"6 "context"
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9+ "hash/fnv"
9 "net/url"10 "net/url"
10 "slices"11 "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
14+ "sync"
13 "unicode/utf8"15 "unicode/utf8"
14 16
15 kv "bnema.dev/ermite-kv"17 kv "bnema.dev/ermite-kv"
@@ -44,14 +46,15 @@ const (
44 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the46 // MergeRequests is the ermite-kv backed ports.MergeRequestStore. It shares the
45 // database of the Store it comes from.47 // database of the Store it comes from.
46 type MergeRequests struct {48 type MergeRequests struct {
47- db *kv.DB49+ db *kv.DB
50+ locks *numberLocks
48 }51 }
49 52
50 var _ ports.MergeRequestStore = (*MergeRequests)(nil)53 var _ ports.MergeRequestStore = (*MergeRequests)(nil)
51 54
52 // MergeRequests returns the merge request store over the same database. It is55 // MergeRequests returns the merge request store over the same database. It is
53 // valid until the Store is closed.56 // valid until the Store is closed.
54-func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db} }57+func (s *Store) MergeRequests() *MergeRequests { return &MergeRequests{db: s.db, locks: s.locks} }
55 58
56 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }59 func mrRepoPrefix(repo domain.RepoName) string { return mrPrefix + repo.String() + "/" }
57 func mrKey(repo domain.RepoName, n int64) []byte {60 func mrKey(repo domain.RepoName, n int64) []byte {
@@ -198,47 +201,90 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.
198 //201 //
199 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy202 //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy
200 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {203 func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
204+ err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error {
205+ mr.Number = n
206+ val, err := encodeMergeRequest(mr)
207+ if err != nil {
208+ return err
209+ }
210+ b.RequireAbsent(mrKey(mr.Repo, n))
211+ b.Put(mrKey(mr.Repo, n), val)
212+ indexOpen(b, nil, mr)
213+ return nil
214+ })
215+ if err != nil {
216+ return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
217+ }
218+ return m.Get(ctx, mr.Repo, mr.Number)
219+}
220+
221+// createNumbered takes the next number of the repository from mrseq/<repo>,
222+// shared by the merge requests and the issues, and lets add put the record
223+// under it into a batch that requires the sequence to be unchanged since it was
224+// read (absent for the first number) and the repository to be unchanged (see
225+// guardRepoKey): two concurrent creations, of either kind, never share a
226+// number. It retries when the batch conflicts; a missing repository gives
227+// domain.ErrNotFound.
228+//
229+// Creations in one repository are serialized in the process (numberLocks), so
230+// a burst of merge requests and issues never exhausts the retries; the version
231+// checks still guard against any other writer.
232+func createNumbered(ctx context.Context, db *kv.DB, locks *numberLocks, repo domain.RepoName, add func(b *kv.Batch, number int64) error) error {
233+ unlock := locks.lock(repo)
234+ defer unlock()
201 for range createTries {235 for range createTries {
202- repoItem, repoOK := m.db.Get(ctx, repoKey(mr.Repo))236+ repoItem, repoOK := db.Get(ctx, repoKey(repo))
203 if !repoOK {237 if !repoOK {
204- return domain.MergeRequest{}, domain.ErrNotFound238+ return domain.ErrNotFound
205 }239 }
206 var last int64240 var last int64
207- item, ok := m.db.Get(ctx, seqKey(mr.Repo))241+ item, ok := db.Get(ctx, seqKey(repo))
208 if ok {242 if ok {
209 var seq storagepb.Sequence243 var seq storagepb.Sequence
210 if err := proto.Unmarshal(item.Value, &seq); err != nil {244 if err := proto.Unmarshal(item.Value, &seq); err != nil {
211- return domain.MergeRequest{}, fmt.Errorf("decode merge request sequence of %q: %w", mr.Repo, err)245+ return fmt.Errorf("decode sequence of %q: %w", repo, err)
212 }246 }
213 last = seq.GetLast()247 last = seq.GetLast()
214 }248 }
215- mr.Number = last + 1249+ number := last + 1
216- val, err := encodeMergeRequest(mr)
217- if err != nil {
218- return domain.MergeRequest{}, err
219- }
220 var b kv.Batch250 var b kv.Batch
221- b.RequireVersion(repoKey(mr.Repo), repoItem.Version)251+ b.RequireVersion(repoKey(repo), repoItem.Version)
222- b.Put(repoKey(mr.Repo), repoItem.Value) // see guardRepo252+ b.Put(repoKey(repo), repoItem.Value) // see guardRepoKey
223 if ok {253 if ok {
224- b.RequireVersion(seqKey(mr.Repo), item.Version)254+ b.RequireVersion(seqKey(repo), item.Version)
225 } else {255 } else {
226- b.RequireAbsent(seqKey(mr.Repo))256+ b.RequireAbsent(seqKey(repo))
227 }257 }
228- b.Put(seqKey(mr.Repo), mustMarshal(&storagepb.Sequence{Last: mr.Number}))258+ b.Put(seqKey(repo), mustMarshal(&storagepb.Sequence{Last: number}))
229- b.RequireAbsent(mrKey(mr.Repo, mr.Number))259+ if err := add(&b, number); err != nil {
230- b.Put(mrKey(mr.Repo, mr.Number), val)260+ return err
231- indexOpen(&b, nil, mr)261+ }
232- _, err = m.db.Apply(ctx, &b)262+ _, err := db.Apply(ctx, &b)
233 if errors.Is(err, kv.ErrConflict) {263 if errors.Is(err, kv.ErrConflict) {
234 continue264 continue
235 }265 }
236- if err != nil {266+ return err
237- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, err)
238- }
239- return m.Get(ctx, mr.Repo, mr.Number)
240 }267 }
241- return domain.MergeRequest{}, fmt.Errorf("create merge request of %q: %w", mr.Repo, domain.ErrConflict)268+ return domain.ErrConflict
269+}
270+
271+// numberLockStripes is the size of the table of numbering locks.
272+const numberLockStripes = 64
273+
274+// numberLocks is a fixed table of mutexes, one stripe per hash of the
275+// repository name, for createNumbered: the memory is bounded whatever the
276+// number of repositories, and two repositories sharing a stripe only wait for
277+// each other. One table is shared by every store of a Store, so the merge
278+// requests and the issues of a repository are numbered one at a time.
279+type numberLocks [numberLockStripes]sync.Mutex
280+
281+// lock locks the numbering of repo and returns the unlock function.
282+func (l *numberLocks) lock(repo domain.RepoName) func() {
283+ h := fnv.New32a()
284+ _, _ = h.Write([]byte(repo.String()))
285+ mu := &l[h.Sum32()%numberLockStripes]
286+ mu.Lock()
287+ return mu.Unlock
242 }288 }
243 289
244 // Get reads mr/<repo>/<number>.290 // Get reads mr/<repo>/<number>.
@@ -344,6 +390,52 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344 return out, nil390 return out, nil
345 }391 }
346 392
393+// ListOpenAll returns the open merge requests of every repository, repository
394+// by repository and highest number first, through the mropen/ index: the
395+// requests it names are read, no other.
396+//
397+//hexcheck:ignore suspicious-business-logic-in-adapter reads the open merge requests of all repositories through their index: a storage concern, no policy
398+func (m *MergeRequests) ListOpenAll(ctx context.Context) ([]domain.MergeRequest, error) {
399+ type ref struct {
400+ repo domain.RepoName
401+ n int64
402+ }
403+ var refs []ref
404+ for key := range m.db.Snapshot(ctx).Prefix(ctx, []byte(mrOpenPrefix)) {
405+ if err := ctx.Err(); err != nil {
406+ return nil, err
407+ }
408+ rest := strings.TrimPrefix(string(key), mrOpenPrefix)
409+ repoName, _, ok := strings.Cut(rest, "/")
410+ if !ok {
411+ return nil, fmt.Errorf("index key %q", key)
412+ }
413+ repo, err := domain.ParseRepoName(repoName)
414+ if err != nil {
415+ return nil, fmt.Errorf("index key %q: %w", key, err)
416+ }
417+ n, err := strconv.ParseInt(rest[strings.LastIndexByte(rest, '/')+1:], 10, 64)
418+ if err != nil {
419+ return nil, fmt.Errorf("index key %q: %w", key, err)
420+ }
421+ refs = append(refs, ref{repo, n})
422+ }
423+ var out []domain.MergeRequest
424+ for _, r := range refs {
425+ mr, err := m.Get(ctx, r.repo, r.n)
426+ if errors.Is(err, domain.ErrNotFound) {
427+ continue // closed or deleted since the snapshot of the index
428+ }
429+ if err != nil {
430+ return nil, err
431+ }
432+ if mr.IsOpen() {
433+ out = append(out, mr)
434+ }
435+ }
436+ return out, nil
437+}
438+
347 // CountOpen counts the open merge requests of the repository from the keys of439 // CountOpen counts the open merge requests of the repository from the keys of
348 // the index: no request is decoded.440 // the index: no request is decoded.
349 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {441 func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
@@ -689,6 +781,11 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
689 }781 }
690 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)782 pb.AllowedMergeModes = append(pb.AllowedMergeModes, mode)
691 }783 }
784+ issues, err := encodeIssuesMode(s.Issues)
785+ if err != nil {
786+ return nil, err
787+ }
788+ pb.Issues = issues
692 return pb, nil789 return pb, nil
693 }790 }
694 791
@@ -713,5 +810,5 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
713 if err != nil {810 if err != nil {
714 return domain.DefaultRepoSettings()811 return domain.DefaultRepoSettings()
715 }812 }
716- return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock()}813+ return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
717 }814 }
M internal/adapters/kvstore/mergerequests_test.go +1 -1
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441441 require.NoError(t, err)
442442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}
444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445445 require.NoError(t, s.Update(ctx, got))
446446 again, err := s.Get(ctx, n)
447447 require.NoError(t, err)
@@ -441,7 +441,7 @@ func TestRepoSettings(t *testing.T) {
441 require.NoError(t, err)441 require.NoError(t, err)
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443 443
444- got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false}444+ got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445 require.NoError(t, s.Update(ctx, got))445 require.NoError(t, s.Update(ctx, got))
446 again, err := s.Get(ctx, n)446 again, err := s.Get(ctx, n)
447 require.NoError(t, err)447 require.NoError(t, err)
M internal/adapters/kvstore/repos.go +3 -1
@@ -144,7 +144,9 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144144 for range deleteTries {
145145 snap := s.db.Snapshot(ctx)
146146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, pendingKeys(ctx, snap, name)...)
149+ keys = append(keys, ciKeys(ctx, snap, name)...)
148150 keys = append(keys, settingsKeys(ctx, snap, name)...)
149151 for len(keys) > deleteChunk {
150152 var b kv.Batch
@@ -144,7 +144,9 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
144 for range deleteTries {144 for range deleteTries {
145 snap := s.db.Snapshot(ctx)145 snap := s.db.Snapshot(ctx)
146 repoItem, repoOK := snap.Get(ctx, repoKey(name))146 repoItem, repoOK := snap.Get(ctx, repoKey(name))
147- keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)147+ keys := append(mergeRequestKeys(ctx, snap, name), issueKeys(ctx, snap, name)...)
148+ keys = append(keys, pendingKeys(ctx, snap, name)...)
149+ keys = append(keys, ciKeys(ctx, snap, name)...)
148 keys = append(keys, settingsKeys(ctx, snap, name)...)150 keys = append(keys, settingsKeys(ctx, snap, name)...)
149 for len(keys) > deleteChunk {151 for len(keys) > deleteChunk {
150 var b kv.Batch152 var b kv.Batch
M internal/adapters/kvstore/store.go +75 -4
@@ -8,12 +8,14 @@ import (
88 "context"
99 "errors"
1010 "fmt"
11+ "slices"
1112
1213 kv "bnema.dev/ermite-kv"
1314 "github.com/bnema/zerowrap"
1415 "google.golang.org/protobuf/proto"
1516
1617 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
1719 "bnema.dev/ermite/internal/ports"
1820 )
1921
@@ -27,8 +29,18 @@ import (
2729 // (CIRun.head_sha: it would attach their checks to the merge commit, where
2830 // nothing looks for them), and the index of the open merge requests by source
2931 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).
31-const SchemaVersion = 3
32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
33+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. It also adds the pending issues of the
37+// public form (ispend/<repo>/<id>, removed with their repository), the bans of
38+// their submitters (isban/<hex hash>) and the form's server key
39+// (meta/issuekey). The upgrade also adds mr:review and issue:write (each only
40+// if missing) to the stored tokens that held all nine original scopes with no
41+// repository list and no branch prefix, so that they keep the owner rights (see
42+// upgradeTokens).
43+const SchemaVersion = 4
3244
3345 const schemaKey = "meta/schema"
3446
@@ -37,7 +49,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
3749
3850 // Store is the ermite-kv backed metadata store.
3951 type Store struct {
40- db *kv.DB
52+ db *kv.DB
53+ locks *numberLocks // shared by the merge request and issue stores
4154 }
4255
4356 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -50,7 +63,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
5063 if err != nil {
5164 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
5265 }
53- s := &Store{db: db}
66+ s := &Store{db: db, locks: new(numberLocks)}
5467 if err := s.checkSchema(ctx); err != nil {
5568 _ = db.Close(context.WithoutCancel(ctx))
5669 return nil, err
@@ -125,8 +138,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125138 if sv.GetVersion() < SchemaVersion {
126139 // Older data stays readable as it is (the changes are additive);
127140 // the new version is recorded so that older builds refuse it.
141+ // The tokens are upgraded in the same batch, so that a crash
142+ // leaves the old version and the upgrade runs again.
128143 var b kv.Batch
129144 b.RequireVersion(key, item.Version)
145+ if sv.GetVersion() < 4 {
146+ if err := s.upgradeTokens(ctx, &b); err != nil {
147+ return err
148+ }
149+ }
130150 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131151 _, err := s.db.Apply(ctx, &b)
132152 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +171,54 @@ func mustMarshal(m proto.Message) []byte {
151171 }
152172 return b
153173 }
174+
175+// preIssueScopes are the nine original scopes, before mr:review and issue:write.
176+var preIssueScopes = []domain.Scope{
177+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
178+ domain.ScopeMRWrite, domain.ScopeMRMerge,
179+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
180+ domain.ScopeTokenAdmin,
181+}
182+
183+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
184+// holds all of preIssueScopes, limited to no repository and no branch prefix,
185+// had the owner rights, which AllScopes now needs mr:review and issue:write
186+// for, so it gets each of them if missing (this is no escalation). Every other
187+// token is left as it is. The batch requires each token to be at the version it
188+// was read at.
189+//
190+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
191+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
193+ pb := &storagepb.Token{}
194+ if err := proto.Unmarshal(item.Value, pb); err != nil {
195+ return fmt.Errorf("upgrade token %s: %w", key, err)
196+ }
197+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 {
198+ continue
199+ }
200+ full := true
201+ for _, sc := range preIssueScopes {
202+ if !slices.Contains(pb.GetScopes(), sc.String()) {
203+ full = false
204+ break
205+ }
206+ }
207+ if !full {
208+ continue
209+ }
210+ added := false
211+ for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
212+ if !slices.Contains(pb.GetScopes(), sc.String()) {
213+ pb.Scopes = append(pb.Scopes, sc.String())
214+ added = true
215+ }
216+ }
217+ if !added {
218+ continue
219+ }
220+ b.RequireVersion(slices.Clone(key), item.Version)
221+ b.Put(slices.Clone(key), mustMarshal(pb))
222+ }
223+ return nil
224+}
@@ -8,12 +8,14 @@ import (
8 "context"8 "context"
9 "errors"9 "errors"
10 "fmt"10 "fmt"
11+ "slices"
11 12
12 kv "bnema.dev/ermite-kv"13 kv "bnema.dev/ermite-kv"
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "google.golang.org/protobuf/proto"15 "google.golang.org/protobuf/proto"
15 16
16 storagepb "bnema.dev/ermite/gen/ermite/storage"17 storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"19 "bnema.dev/ermite/internal/ports"
18 )20 )
19 21
@@ -27,8 +29,18 @@ import (
27 // (CIRun.head_sha: it would attach their checks to the merge commit, where29 // (CIRun.head_sha: it would attach their checks to the merge commit, where
28 // nothing looks for them), and the index of the open merge requests by source30 // nothing looks for them), and the index of the open merge requests by source
29 // branch (mropen/: an older build would not keep it up to date; the upgrade31 // branch (mropen/: an older build would not keep it up to date; the upgrade
30-// builds it once, see BuildOpenIndex).32+// builds it once, see BuildOpenIndex). Version 4 adds the issues (is/, isc/,
31-const SchemaVersion = 333+// isopen/, numbered from the same mrseq/ sequence as the merge requests, and
34+// RepoSettings.issues): an older build would not delete them with their
35+// repository, nor keep the numbers apart, and it would misread the issue:write
36+// scope, so it must refuse the data. It also adds the pending issues of the
37+// public form (ispend/<repo>/<id>, removed with their repository), the bans of
38+// their submitters (isban/<hex hash>) and the form's server key
39+// (meta/issuekey). The upgrade also adds mr:review and issue:write (each only
40+// if missing) to the stored tokens that held all nine original scopes with no
41+// repository list and no branch prefix, so that they keep the owner rights (see
42+// upgradeTokens).
43+const SchemaVersion = 4
32 44
33 const schemaKey = "meta/schema"45 const schemaKey = "meta/schema"
34 46
@@ -37,7 +49,8 @@ var ErrSchemaTooNew = errors.New("storage schema is newer than this ermite suppo
37 49
38 // Store is the ermite-kv backed metadata store.50 // Store is the ermite-kv backed metadata store.
39 type Store struct {51 type Store struct {
40- db *kv.DB52+ db *kv.DB
53+ locks *numberLocks // shared by the merge request and issue stores
41 }54 }
42 55
43 var _ ports.RepoMetaStore = (*Store)(nil)56 var _ ports.RepoMetaStore = (*Store)(nil)
@@ -50,7 +63,7 @@ func Open(ctx context.Context, dir string) (*Store, error) {
50 if err != nil {63 if err != nil {
51 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)64 return nil, fmt.Errorf("open ermite-kv in %s: %w", dir, err)
52 }65 }
53- s := &Store{db: db}66+ s := &Store{db: db, locks: new(numberLocks)}
54 if err := s.checkSchema(ctx); err != nil {67 if err := s.checkSchema(ctx); err != nil {
55 _ = db.Close(context.WithoutCancel(ctx))68 _ = db.Close(context.WithoutCancel(ctx))
56 return nil, err69 return nil, err
@@ -125,8 +138,15 @@ func (s *Store) checkSchema(ctx context.Context) error {
125 if sv.GetVersion() < SchemaVersion {138 if sv.GetVersion() < SchemaVersion {
126 // Older data stays readable as it is (the changes are additive);139 // Older data stays readable as it is (the changes are additive);
127 // the new version is recorded so that older builds refuse it.140 // the new version is recorded so that older builds refuse it.
141+ // The tokens are upgraded in the same batch, so that a crash
142+ // leaves the old version and the upgrade runs again.
128 var b kv.Batch143 var b kv.Batch
129 b.RequireVersion(key, item.Version)144 b.RequireVersion(key, item.Version)
145+ if sv.GetVersion() < 4 {
146+ if err := s.upgradeTokens(ctx, &b); err != nil {
147+ return err
148+ }
149+ }
130 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))150 b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
131 _, err := s.db.Apply(ctx, &b)151 _, err := s.db.Apply(ctx, &b)
132 if errors.Is(err, kv.ErrConflict) {152 if errors.Is(err, kv.ErrConflict) {
@@ -151,3 +171,54 @@ func mustMarshal(m proto.Message) []byte {
151 }171 }
152 return b172 return b
153 }173 }
174+
175+// preIssueScopes are the nine original scopes, before mr:review and issue:write.
176+var preIssueScopes = []domain.Scope{
177+ domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeRepoAdmin,
178+ domain.ScopeMRWrite, domain.ScopeMRMerge,
179+ domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister,
180+ domain.ScopeTokenAdmin,
181+}
182+
183+// upgradeTokens adds to b the schema 4 upgrade of the tokens: a token that
184+// holds all of preIssueScopes, limited to no repository and no branch prefix,
185+// had the owner rights, which AllScopes now needs mr:review and issue:write
186+// for, so it gets each of them if missing (this is no escalation). Every other
187+// token is left as it is. The batch requires each token to be at the version it
188+// was read at.
189+//
190+//hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy
191+func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error {
192+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) {
193+ pb := &storagepb.Token{}
194+ if err := proto.Unmarshal(item.Value, pb); err != nil {
195+ return fmt.Errorf("upgrade token %s: %w", key, err)
196+ }
197+ if len(pb.GetRepos()) > 0 || len(pb.GetBranchPrefixes()) > 0 {
198+ continue
199+ }
200+ full := true
201+ for _, sc := range preIssueScopes {
202+ if !slices.Contains(pb.GetScopes(), sc.String()) {
203+ full = false
204+ break
205+ }
206+ }
207+ if !full {
208+ continue
209+ }
210+ added := false
211+ for _, sc := range []domain.Scope{domain.ScopeMRReview, domain.ScopeIssueWrite} {
212+ if !slices.Contains(pb.GetScopes(), sc.String()) {
213+ pb.Scopes = append(pb.Scopes, sc.String())
214+ added = true
215+ }
216+ }
217+ if !added {
218+ continue
219+ }
220+ b.RequireVersion(slices.Clone(key), item.Version)
221+ b.Put(slices.Clone(key), mustMarshal(pb))
222+ }
223+ return nil
224+}
M internal/adapters/kvstore/store_test.go +4 -4
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231231 })
232232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {
233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234234 dir := t.TempDir()
235235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))
264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265265 })
266266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {
267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268268 dir := t.TempDir()
269269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273273 _, err = s.Get(ctx, name(t, "old"))
274274 require.NoError(t, err)
275275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))
276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277277 })
278278
279279 // Downgrade guard: once a build with reusable workflows has opened the data
@@ -230,7 +230,7 @@ func TestSchema(t *testing.T) {
230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))230 assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
231 })231 })
232 232
233- t.Run("version 2 is upgraded to 3, its runs kept", func(t *testing.T) {233+ t.Run("version 2 is upgraded to the current one, its runs kept", func(t *testing.T) {
234 dir := t.TempDir()234 dir := t.TempDir()
235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))235 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))236 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -261,10 +261,10 @@ func TestSchema(t *testing.T) {
261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)261 assert.Equal(t, "cccccccccccccccccccccccccccccccccccccccc", pr.HeadSha)
262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")262 assert.Equal(t, pr.HeadSha, pr.CheckSha(), "the checks of a pull_request run are on the head")
263 require.NoError(t, s.Close(ctx))263 require.NoError(t, s.Close(ctx))
264- assert.Equal(t, uint32(3), readSchema(t, dir))264+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
265 })265 })
266 266
267- t.Run("version 2 is upgraded to 3, its data kept", func(t *testing.T) {267+ t.Run("version 2 is upgraded to the current one, its data kept", func(t *testing.T) {
268 dir := t.TempDir()268 dir := t.TempDir()
269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))269 putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))270 putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
@@ -273,7 +273,7 @@ func TestSchema(t *testing.T) {
273 _, err = s.Get(ctx, name(t, "old"))273 _, err = s.Get(ctx, name(t, "old"))
274 require.NoError(t, err)274 require.NoError(t, err)
275 require.NoError(t, s.Close(ctx))275 require.NoError(t, s.Close(ctx))
276- assert.Equal(t, uint32(3), readSchema(t, dir))276+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
277 })277 })
278 278
279 // Downgrade guard: once a build with reusable workflows has opened the data279 // Downgrade guard: once a build with reusable workflows has opened the data
M internal/adapters/rpc/api.go +8 -3
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
3737 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
4141 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,11 @@ type Config struct {
5555 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
5656 MergeRequests ports.MergeRequestService
5757 SigningKey ports.SigningKeyService
58- Version string
58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
5963 // TrustedProxies are the networks allowed to set X-Forwarded-For.
6064 TrustedProxies []netip.Prefix
6165 // Limiter counts failed authentications; nil builds a default one. Share
@@ -141,7 +145,8 @@ func New(cfg Config) *API {
141145 features = append(features, FeatureCommitSignatures)
142146 }
143147 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144- mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
148+ mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{mrs: cfg.MergeRequests}, opts...))
149+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
145150 if cfg.Sessions != nil {
146151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147152 }
@@ -35,7 +35,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top35 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services36 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.37 // are, and FeatureCommitSignatures when Config.TrustedKeys is set.
38-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.workflows"}
39 39
40 // FeaturePasskeys is listed by GetVersion when passkeys are available.40 // FeaturePasskeys is listed by GetVersion when passkeys are available.
41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.41 // FeatureCIArtifacts (artifact.go) when the artifact services are served.
@@ -55,7 +55,11 @@ type Config struct {
55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService57 SigningKey ports.SigningKeyService
58- Version string58+ // Issues serves IssueService.
59+ Issues ports.IssueService
60+ // IssueModeration serves the moderation calls of IssueService.
61+ IssueModeration ports.IssueModerationService
62+ Version string
59 // TrustedProxies are the networks allowed to set X-Forwarded-For.63 // TrustedProxies are the networks allowed to set X-Forwarded-For.
60 TrustedProxies []netip.Prefix64 TrustedProxies []netip.Prefix
61 // Limiter counts failed authentications; nil builds a default one. Share65 // Limiter counts failed authentications; nil builds a default one. Share
@@ -141,7 +145,8 @@ func New(cfg Config) *API {
141 features = append(features, FeatureCommitSignatures)145 features = append(features, FeatureCommitSignatures)
142 }146 }
143 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))147 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
144- mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))148+ mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{mrs: cfg.MergeRequests}, opts...))
149+ mux.Handle(ermiteconnect.NewIssueServiceHandler(&issueService{issues: cfg.Issues, moderation: cfg.IssueModeration}, opts...))
145 if cfg.Sessions != nil {150 if cfg.Sessions != nil {
146 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))151 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
147 }152 }
M internal/adapters/rpc/convert.go +19 -1
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
7575 }
7676
7777 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
78+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
7979 for _, m := range s.AllowedMergeModes {
8080 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
8181 }
@@ -134,6 +134,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
134134 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
135135 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
136136 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
137+ domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
137138 }
138139
139140 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -472,6 +473,21 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
472473 case domain.CommentAdded:
473474 out.Time = timestamp(ev.At)
474475 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
476+ case domain.IssueOpened:
477+ out.Time = timestamp(ev.At)
478+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
479+ case domain.IssueClosed:
480+ out.Time = timestamp(ev.At)
481+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
482+ case domain.IssueReopened:
483+ out.Time = timestamp(ev.At)
484+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
485+ case domain.IssueCommented:
486+ out.Time = timestamp(ev.At)
487+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
488+ case domain.IssueSubmitted:
489+ out.Time = timestamp(ev.At)
490+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
475491 case domain.CommentEdited:
476492 out.Time = timestamp(ev.At)
477493 out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
@@ -488,6 +504,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
488504 out.Time = timestamp(ev.At)
489505 out.Kind = jobEventToPB(ev)
490506 default:
507+ // Web-only events, such as domain.IssuePendingRemoved (the API has
508+ // no message for it; watchers list pending issues on demand).
491509 return nil
492510 }
493511 return out
@@ -75,7 +75,7 @@ func repoToPB(v ports.RepoView) *pb.Repo {
75 }75 }
76 76
77 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {77 func settingsToPB(s domain.RepoSettings) *pb.RepoSettings {
78- out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}78+ out := &pb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, Issues: issuesModeToPB(s.Issues)}
79 for _, m := range s.AllowedMergeModes {79 for _, m := range s.AllowedMergeModes {
80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))80 out.AllowedMergeModes = append(out.AllowedMergeModes, mergeModeToPB(m))
81 }81 }
@@ -134,6 +134,7 @@ var scopeToPB = map[domain.Scope]pb.Scope{
134 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,134 domain.ScopeSecretsWrite: pb.Scope_SCOPE_SECRETS_WRITE,
135 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,135 domain.ScopeRunnerRegister: pb.Scope_SCOPE_RUNNER_REGISTER,
136 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,136 domain.ScopeTokenAdmin: pb.Scope_SCOPE_TOKEN_ADMIN,
137+ domain.ScopeIssueWrite: pb.Scope_SCOPE_ISSUE_WRITE,
137 }138 }
138 139
139 func scopeFromPB(s pb.Scope) (domain.Scope, error) {140 func scopeFromPB(s pb.Scope) (domain.Scope, error) {
@@ -472,6 +473,21 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
472 case domain.CommentAdded:473 case domain.CommentAdded:
473 out.Time = timestamp(ev.At)474 out.Time = timestamp(ev.At)
474 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}475 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
476+ case domain.IssueOpened:
477+ out.Time = timestamp(ev.At)
478+ out.Kind = &pb.Event_IssueOpened{IssueOpened: &pb.IssueOpened{Issue: issueToPB(ev.Issue)}}
479+ case domain.IssueClosed:
480+ out.Time = timestamp(ev.At)
481+ out.Kind = &pb.Event_IssueClosed{IssueClosed: &pb.IssueClosed{Issue: issueToPB(ev.Issue)}}
482+ case domain.IssueReopened:
483+ out.Time = timestamp(ev.At)
484+ out.Kind = &pb.Event_IssueReopened{IssueReopened: &pb.IssueReopened{Issue: issueToPB(ev.Issue)}}
485+ case domain.IssueCommented:
486+ out.Time = timestamp(ev.At)
487+ out.Kind = &pb.Event_IssueCommented{IssueCommented: &pb.IssueCommented{Repo: ev.Repo.String(), Number: ev.Number, Comment: issueCommentToPB(ev.Comment)}}
488+ case domain.IssueSubmitted:
489+ out.Time = timestamp(ev.At)
490+ out.Kind = &pb.Event_IssueSubmitted{IssueSubmitted: &pb.IssueSubmitted{Repo: ev.Repo.String(), Id: ev.ID.String()}}
475 case domain.CommentEdited:491 case domain.CommentEdited:
476 out.Time = timestamp(ev.At)492 out.Time = timestamp(ev.At)
477 out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}493 out.Kind = &pb.Event_CommentEdited{CommentEdited: &pb.CommentEdited{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
@@ -488,6 +504,8 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
488 out.Time = timestamp(ev.At)504 out.Time = timestamp(ev.At)
489 out.Kind = jobEventToPB(ev)505 out.Kind = jobEventToPB(ev)
490 default:506 default:
507+ // Web-only events, such as domain.IssuePendingRemoved (the API has
508+ // no message for it; watchers list pending issues on demand).
491 return nil509 return nil
492 }510 }
493 return out511 return out
M internal/adapters/rpc/convert_test.go +40 -0
A internal/adapters/rpc/issue.go +262 -0
M internal/adapters/rpc/mergerequest.go +3 -1
M internal/adapters/rpc/mergerequest_test.go +11 -11
M internal/adapters/rpc/repo.go +10 -2
M internal/adapters/web/ASSETS.md +2 -0
M internal/adapters/web/assets.go +3 -1
M internal/adapters/web/events.go +59 -3
M internal/adapters/web/events_test.go +38 -1
M internal/adapters/web/fragments.go +36 -6
M internal/adapters/web/fragments.templ +1 -1
M internal/adapters/web/fragments_templ.go +1 -1
M internal/adapters/web/home.go +3 -0
A internal/adapters/web/inbox.go +294 -0
A internal/adapters/web/inbox.templ +115 -0
A internal/adapters/web/inbox_moderation_test.go +148 -0
A internal/adapters/web/inbox_templ.go +759 -0
A internal/adapters/web/inbox_test.go +310 -0
A internal/adapters/web/issues.go +363 -0
A internal/adapters/web/issues.templ +187 -0
A internal/adapters/web/issues_form.go +276 -0
A internal/adapters/web/issues_form.templ +67 -0
A internal/adapters/web/issues_form_templ.go +421 -0
A internal/adapters/web/issues_form_test.go +345 -0
A internal/adapters/web/issues_post.go +86 -0
A internal/adapters/web/issues_templ.go +885 -0
A internal/adapters/web/issues_test.go +332 -0
A internal/adapters/web/jstest/issue-form.test.mjs +183 -0
M internal/adapters/web/keys.templ +1 -0
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/markdown.go +73 -7
M internal/adapters/web/markdown_test.go +67 -0
M internal/adapters/web/mr.go +13 -8
M internal/adapters/web/mr.templ +10 -3
M internal/adapters/web/mr_post.go +8 -1
M internal/adapters/web/mr_templ.go +289 -265
M internal/adapters/web/overlays.templ +1 -1
M internal/adapters/web/overlays_templ.go +1 -1
M internal/adapters/web/pages.templ +8 -0
M internal/adapters/web/pages_templ.go +409 -363
M internal/adapters/web/repo.go +5 -0
M internal/adapters/web/session.go +16 -1
M internal/adapters/web/shell.templ +3 -0
M internal/adapters/web/shell_templ.go +180 -161
M internal/adapters/web/static/css/60-mr.css +25 -0
M internal/adapters/web/static/js/ermite.js +2 -2
A internal/adapters/web/static/js/issue-form.js +175 -0
A internal/adapters/web/static/js/pow-worker.js +72 -0
M internal/adapters/web/view.go +12 -0
M internal/adapters/web/web.go +46 -1
M internal/app/api_helpers_test.go +2 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +2 -2
M internal/app/cli_fake_test.go +27 -0
A internal/app/cli_issue.go +455 -0
A internal/app/cli_issue_e2e_test.go +154 -0
A internal/app/cli_issue_test.go +264 -0
M internal/app/cli_repo.go +61 -6
M internal/app/cli_repo_test.go +6 -5
M internal/app/cli_token.go +22 -0
M internal/app/cli_token_test.go +2 -3
M internal/app/cli_version.go +1 -0
M internal/app/cli_version_test.go +5 -5
M internal/app/cli_watch_test.go +1 -1
M internal/app/forge.go +71 -22
A internal/app/issue_api_test.go +245 -0
A internal/app/issue_moderation_api_test.go +139 -0
M internal/app/root.go +1 -1
M internal/app/testdata/web/blob-binary.html +3 -1
M internal/app/testdata/web/blob-code.html +3 -1
M internal/app/testdata/web/blob-empty.html +3 -1
M internal/app/testdata/web/blob-image.html +3 -1
M internal/app/testdata/web/blob-markdown.html +3 -1
M internal/app/testdata/web/blob-svg.html +3 -1
M internal/app/testdata/web/blob-text.html +3 -1
M internal/app/testdata/web/blob-too-large.html +3 -1
M internal/app/testdata/web/blob-trojan.html +3 -1
M internal/app/testdata/web/commit-big.html +3 -1
M internal/app/testdata/web/commit-binary.html +3 -1
M internal/app/testdata/web/commit-merge.html +3 -1
M internal/app/testdata/web/commit-modify-split.html +3 -1
M internal/app/testdata/web/commit-modify.html +3 -1
M internal/app/testdata/web/commit-rename.html +3 -1
M internal/app/testdata/web/commit-root.html +3 -1
M internal/app/testdata/web/commit-trojan.html +3 -1
M internal/app/testdata/web/commits-page2.html +3 -1
M internal/app/testdata/web/commits-path.html +3 -1
M internal/app/testdata/web/commits-root.html +4 -2
M internal/app/testdata/web/commits-small.html +4 -2
M internal/app/testdata/web/empty.html +3 -1
M internal/app/testdata/web/home.html +4 -2
M internal/app/testdata/web/not-found-page.html +3 -1
M internal/app/testdata/web/not-found.html +3 -1
M internal/app/testdata/web/refs.html +4 -2
M internal/app/testdata/web/summary.html +4 -2
M internal/app/testdata/web/tree-nested.html +3 -1
M internal/app/testdata/web/tree-root.html +3 -1
M internal/app/testdata/web/tree-slash-ref.html +3 -1
M internal/app/testdata/web/tree-subdir.html +3 -1
M internal/app/web.go +5 -0
A internal/app/web_inbox_test.go +152 -0
A internal/app/web_issue_form_test.go +231 -0
A internal/app/web_issue_test.go +139 -0
M internal/clientlimit/export_test.go +7 -0
A internal/clientlimit/submissions.go +178 -0
A internal/clientlimit/submissions_test.go +148 -0
M internal/domain/event.go +72 -0
A internal/domain/issue.go +106 -0
A internal/domain/issue_test.go +138 -0
M internal/domain/mergerequest.go +3 -1
A internal/domain/pending_issue.go +122 -0
A internal/domain/pending_issue_test.go +90 -0
A internal/domain/submission.go +42 -0
M internal/domain/text.go +102 -0
M internal/domain/token.go +5 -1
M internal/domain/token_test.go +6 -1
M internal/ports/dto.go +69 -0
A internal/ports/mocks/InboxService.go +121 -0
A internal/ports/mocks/IssueBanStore.go +183 -0
A internal/ports/mocks/IssueKeyStore.go +109 -0
A internal/ports/mocks/IssueModerationService.go +280 -0
A internal/ports/mocks/IssueNumberResolver.go +126 -0
A internal/ports/mocks/IssueService.go +523 -0
A internal/ports/mocks/IssueStore.go +515 -0
A internal/ports/mocks/IssueSubmissionService.go +190 -0
M internal/ports/mocks/MergeRequestStore.go +62 -0
A internal/ports/mocks/PendingIssueStore.go +452 -0
A internal/ports/mocks/SubmissionLimiter.go +188 -0
M internal/ports/ports.go +81 -0
M internal/ports/services.go +94 -0
M internal/usecase/events.go +97 -3
A internal/usecase/events_owner_test.go +200 -0
M internal/usecase/events_test.go +43 -0
M internal/usecase/export_test.go +34 -0
A internal/usecase/inbox.go +164 -0
A internal/usecase/inbox_test.go +110 -0
A internal/usecase/issue_keys.go +62 -0
A internal/usecase/issue_moderation.go +119 -0
A internal/usecase/issue_moderation_test.go +251 -0
A internal/usecase/issue_pow_vector_test.go +39 -0
A internal/usecase/issue_submissions.go +383 -0
A internal/usecase/issue_submissions_test.go +670 -0
A internal/usecase/issues.go +291 -0
A internal/usecase/issues_integration_test.go +87 -0
A internal/usecase/issues_test.go +276 -0
M internal/usecase/mergerequests_integration_test.go +1 -1
M internal/usecase/repos_write.go +13 -3
M internal/usecase/repos_write_test.go +42 -0
A proto/ermite/storage/issue.proto +71 -0
M proto/ermite/storage/merge_request.proto +10 -0
M skills/ermite/CLI.md +15 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close