detached aa7340c

Merge 843ebed5ec107f59b860cc5e66bbcaf8e64a8425 into d8e7d5b03a01f5f16f8d49f12a7e132355b40f06

commit
aa7340c5e160a7e6553043c53ccaea045dbe7f20
author
ermite · · 2026-10-19 18:07 UTC
merge of
d8e7d5b + 843ebed · diff against the first parent
signature
unsigned
88 files · +6356 -1026
M README.md +1 -1
@@ -122,7 +122,7 @@ ermite mr show myrepo 1 --since-last-read --mark-read
122122 **5. You merge** (or click Merge in the web interface):
123123
124124 ```sh
125-ermite mr merge myrepo 1 --head <head id> --squash --delete-branch
125+ermite mr merge myrepo 1 --head <head id> --mode squash --delete-branch
126126 ```
127127
128128 Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows the CLI. Rules of agent tokens: [docs/tokens.md](docs/tokens.md).
M docs/cli.md +5 -4
@@ -54,10 +54,10 @@ ermite repo delete <name> [--yes]
5454 ermite token create <label> [--preset cli|agent|ci|runner] [--scope …] [--repo …] [--branch-prefix …] [--expires 30d]
5555 ermite token list | revoke <id>
5656
57-ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
57+ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff] # show prints the mergeability: up to date, behind, conflict
5858 ermite mr comment <repo> <n> -m … [--path … --line N|A-B --side old|new] [--reply <id>] [--approve|--request-changes]
5959 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> [--mode fast-forward|squash|merge] [--delete-branch] | close <repo> <n>
6161
6262 ermite issue create <repo> --title … [--body … | --body-file …] | list <repo> [--state open|closed|all]
6363 ermite issue show <repo> <n> | comment <repo> <n> -m … | close <repo> <n> | reopen <repo> <n>
@@ -78,7 +78,7 @@ ermite environment create|list|delete … --repo <repo>
7878 ermite web login # one-time sign-in link (60 s)
7979 ermite web logout --all # sign every browser out
8080 ermite web passkey list | remove <id>
81-ermite signing-key show # public key of squash commits
81+ermite signing-key show # public key of squash and merge commits (server-signed)
8282 ermite oidc rotate-key
8383 ermite watch [repo] [--json] # follow live events
8484 ermite version # client and server versions
@@ -90,7 +90,8 @@ Issues need a server with the `issues` feature and `ermite repo edit <repo> --is
9090 ## Output
9191
9292 - `--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`.
93-- `watch --json` prints one object per line.
93+- `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
94+- `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
9495 - Errors are one line on stderr (`ermite: …`) with exit status 1.
9596 - Shell completion completes repository names from the server.
9697
M docs/commit-signatures.md +1 -1
@@ -36,5 +36,5 @@ ermite signing-key trust remove <id>
3636
3737 - A key may sign for 1 to 10 emails; up to 100 keys.
3838 - The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP.
39-- The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed.
39+- The server's own key (`ermite signing-key show`), which signs squash and merge commits (generated automatically on first start), is always trusted for the committer email and cannot be removed.
4040 - Private keys are refused.
M docs/configuration.md +2 -2
@@ -34,8 +34,8 @@ ermite serve
3434 |---|---|---|
3535 | `ERMITE_GIT` | `git` | git 2.45 or newer |
3636 | `ERMITE_GIT_READER` | `ermite-git` | `ermite-git` reads with the [ermite-git](https://github.com/bnema/ermite-git) library where it is faster, and falls back to `git`. `cli` uses `git` for every read: set it and restart if a read looks wrong |
37-| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
38-| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
37+| `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash and merge commits |
38+| `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash and merge commits |
3939
4040 ## CI
4141
M docs/merge-requests.md +45 -7
@@ -5,26 +5,64 @@ A merge request proposes a branch for a target (the default branch unless set).
55 ```sh
66 ermite mr create myrepo --source feature --title "Add feature"
77 ermite mr show myrepo 1 --diff
8-ermite mr merge myrepo 1 --head <commit> [--squash] [--delete-branch]
8+ermite mr merge myrepo 1 --head <commit> [--mode fast-forward|squash|merge] [--delete-branch]
99 ```
1010
1111 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
1212 merge request web URL on its own line. With `--json`, merge request objects
1313 (create, show, list, close and merge) include `url` alongside the other fields.
1414
15-- **Fast-forward** moves the target. **Squash** writes one commit signed by the server's SSH key.
16-- The merge is refused when the branch moved since the reviewed `--head`, or when the target is not an ancestor of it.
15+## Merge modes
16+
17+| Mode | Result | Needs |
18+|---|---|---|
19+| `fast-forward` (default) | the target moves to the head, no commit | the target is an ancestor of the head |
20+| `squash` | one new commit with the merged tree, the target tip as only parent | the branch merges without conflicts |
21+| `merge` | a merge commit with two parents, the target tip and the head | the branch merges without conflicts |
22+
23+- The server never rebases. A branch behind its target merges with `squash` or `merge`, or is rebased and pushed again for a fast-forward.
24+- The server checks the mode again when it merges, whatever the badge showed, and refuses with the reason (the target moved, a conflict with the paths).
25+- The merge is also refused when the branch moved since the reviewed `--head`.
1726 - With `failing_checks_block` on (default), the merge is refused while a check of the head failed or is still running.
18-- `--delete-branch` deletes the source branch, unless it is the default branch or another open request uses it.
19-- `ermite repo edit --merge-modes` limits the modes per repository.
27+- The merge answers as soon as the target has moved. `--delete-branch` then deletes the source branch right after, in the background (unless it is the default branch or another open request uses it); if the branch moved meanwhile, it is kept.
28+- Every mode is allowed by default, including in repositories that had only `fast-forward` and `squash` before `merge` existed: those gain `merge`. Disable a mode per repository with `ermite repo edit <repo> --merge-modes fast-forward,squash` (list the modes to keep).
2029 - Scopes: `mr:write` to open and comment, `mr:review` to give a verdict, `mr:merge` to merge.
2130
22-Verify squash commits with the `allowed_signers` line from `ermite signing-key show`:
31+## Signed commits
32+
33+Squash and merge commits are signed by ermite's own key. The key is generated automatically the first time the server starts, belongs to the server, and its committer identity is `ERMITE_COMMITTER_NAME` / `ERMITE_COMMITTER_EMAIL`. `ermite signing-key show` prints its fingerprint and the `allowed_signers` line; use it to verify locally, or register the public key on GitHub or Forgejo.
34+
35+```sh
36+ermite signing-key show --allowed-signers > allowed_signers
37+git -c gpg.format=ssh -c gpg.ssh.allowedSignersFile=allowed_signers verify-commit <commit>
38+```
39+
40+The merge box of the web page and the output of `mr merge --mode squash|merge` repeat this with the fingerprint.
41+
42+## Mergeability
43+
44+The server checks in the background whether each open request merges with its target, and again whenever the source or the target moves. `mr show` prints it; the web merge box shows it as a live badge; `ermite watch` reports each change.
45+
46+| State | Meaning | Modes possible |
47+|---|---|---|
48+| `checking` | no result yet for the current branches | none (wait) |
49+| `up_to_date` | the target is an ancestor of the head | all |
50+| `behind` | the head lacks `behind` commits of the target (`behind_capped`: at least 1000) but merges without conflicts | `squash`, `merge` |
51+| `conflict` | the merge conflicts; up to 20 `conflict_paths` are listed (`conflict_paths_truncated` when there are more); no paths means no common history | none (rebase) |
2352
2453 ```sh
25-git verify-commit <commit>
54+ermite mr show myrepo 1
55+# mergeability behind by 2, merges without conflicts (squash or merge commit; fast-forward needs a rebase)
2656 ```
2757
58+In `mr show --json`, `mergeability` holds `state`, `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`, `target_id` and `head_id`; it is `null` unless the request is open. `ermite watch` prints `merge_request_mergeability` events when the state changes (not on every push):
59+
60+```text
61+2026-10-19T14:00:00Z merge_request_mergeability myrepo!1 mergeability: behind by 2
62+```
63+
64+In the web merge box a mode that is not possible is disabled with its reason, and the Merge button too when no mode is possible. A merge the server refuses (target moved, conflict, branch moved) shows the page again with the reason in the box.
65+
2866 ## Notes
2967
3068 A note is the only review object. It is published as soon as it is written: there is no draft and no submit step.
M gen/ermite/storage/merge_request.pb.go +216 -24 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.9.0
16+ bnema.dev/ermite-api v0.10.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
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.FeatureIssues, 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.FeatureMergeability, 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.FeatureIssues, client.FeatureSession,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, 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.FeatureIssues, client.FeatureSigningKey, client.FeatureSession,
150+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureIssues, client.FeatureMergeability, 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))
M internal/adapters/client/features.go +4 -1
@@ -54,11 +54,14 @@ const (
5454 // FeatureCommitSignatures is TrustedKeyService and Commit.verification
5555 // (`ermite signing-key trust`).
5656 FeatureCommitSignatures = "commit_signatures"
57+ // FeatureMergeability is MergeRequest.mergeability and the
58+ // merge_request_mergeability event.
59+ FeatureMergeability = "merge_requests.mergeability"
5760 )
5861
5962 // UsedFeatures lists the features the CLI uses, in the order they are reported.
6063 func UsedFeatures() []string {
61- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
64+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureIssues, FeatureMergeability, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIWorkflows, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
6265 }
6366
6467 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/gitcli/git.go +12 -2
@@ -131,6 +131,16 @@ func (g *Git) run(ctx context.Context, args ...string) (string, error) {
131131
132132 // runInput is run with the given standard input; nil means none.
133133 func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134+ out, err := g.runCapture(ctx, stdin, args...)
135+ if err != nil {
136+ return "", err
137+ }
138+ return out, nil
139+}
140+
141+// runCapture is runInput that also returns the standard output of a failed
142+// run.
143+func (g *Git) runCapture(ctx context.Context, stdin io.Reader, args ...string) (string, error) {
134144 if _, ok := ctx.Deadline(); !ok {
135145 var cancel context.CancelFunc
136146 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -156,9 +166,9 @@ func (g *Git) runInput(ctx context.Context, stdin io.Reader, args ...string) (st
156166 }
157167 var exit *exec.ExitError
158168 if errors.As(err, &exit) && msg != "" {
159- return "", fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
169+ return stdout.String(), fmt.Errorf("git %s: %w: %s", subcommand(args), err, msg)
160170 }
161- return "", fmt.Errorf("git %s: %w", subcommand(args), err)
171+ return stdout.String(), fmt.Errorf("git %s: %w", subcommand(args), err)
162172 }
163173 return stdout.String(), nil
164174 }
M internal/adapters/gitcli/merge_test.go +83 -0
@@ -4,6 +4,7 @@ package gitcli_test
44
55 import (
66 "context"
7+ "fmt"
78 "strings"
89 "testing"
910
@@ -231,3 +232,85 @@ func TestDeleteBranch(t *testing.T) {
231232 err = g.DeleteBranch(ctx, name(t, "missing"), "main", m.mainTip)
232233 assert.ErrorIs(t, err, domain.ErrNotFound)
233234 }
235+
236+func TestMergeTree(t *testing.T) {
237+ g, data := newGit(t)
238+ ctx := context.Background()
239+ m := newMergeFixture(t, data)
240+ t.Cleanup(func() { _ = g.Close(ctx) })
241+
242+ // Clean: main is an ancestor of feat.
243+ res, err := g.MergeTree(ctx, m.f.Name, m.mainTip, m.featTip)
244+ require.NoError(t, err)
245+ assert.True(t, res.Clean)
246+ assert.False(t, res.Tree.IsZero())
247+ assert.Empty(t, res.ConflictPaths)
248+
249+ // Conflict: both sides change a.txt and c.txt differently.
250+ m.f.Git("checkout", "--quiet", "-b", "clash", m.mainTip.String())
251+ m.f.Write("a.txt", "one\nTWO\nthree\n")
252+ m.f.Write("c.txt", "other\n")
253+ clash := oidOf(t, m.f.Commit("clash"))
254+ res, err = g.MergeTree(ctx, m.f.Name, clash, m.featTip)
255+ require.NoError(t, err)
256+ assert.False(t, res.Clean)
257+ assert.True(t, res.Tree.IsZero())
258+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, res.ConflictPaths)
259+ assert.False(t, res.Truncated)
260+
261+ // No common history: not clean, no paths.
262+ m.f.Git("checkout", "--quiet", "--orphan", "other")
263+ m.f.Git("rm", "-rf", "--quiet", ".")
264+ m.f.Write("o.txt", "other\n")
265+ other := oidOf(t, m.f.Commit("other root"))
266+ res, err = g.MergeTree(ctx, m.f.Name, other, m.featTip)
267+ require.NoError(t, err)
268+ assert.False(t, res.Clean)
269+ assert.Empty(t, res.ConflictPaths)
270+
271+ // More conflicts than the cap.
272+ m.f.Git("checkout", "--quiet", "-b", "wide-a", m.a.String())
273+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
274+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "base\n")
275+ }
276+ base := oidOf(t, m.f.Commit("many"))
277+ m.f.Git("checkout", "--quiet", "-b", "wide-b", base.String())
278+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
279+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "b\n")
280+ }
281+ wb := oidOf(t, m.f.Commit("b side"))
282+ m.f.Git("checkout", "--quiet", "wide-a")
283+ for i := 0; i < domain.MaxConflictPaths+5; i++ {
284+ m.f.Write(fmt.Sprintf("f%02d.txt", i), "a\n")
285+ }
286+ wa := oidOf(t, m.f.Commit("a side"))
287+ res, err = g.MergeTree(ctx, m.f.Name, wa, wb)
288+ require.NoError(t, err)
289+ assert.False(t, res.Clean)
290+ assert.Len(t, res.ConflictPaths, domain.MaxConflictPaths+5, "the adapter returns every path")
291+ assert.False(t, res.Truncated, "bounding is the domain's")
292+}
293+
294+func TestCountBehind(t *testing.T) {
295+ g, data := newGit(t)
296+ ctx := context.Background()
297+ m := newMergeFixture(t, data)
298+ t.Cleanup(func() { _ = g.Close(ctx) })
299+
300+ // main has b that a-based head lacks.
301+ n, err := g.CountBehind(ctx, m.f.Name, m.a, m.mainTip, 10)
302+ require.NoError(t, err)
303+ assert.Equal(t, 1, n)
304+
305+ n, err = g.CountBehind(ctx, m.f.Name, m.featTip, m.mainTip, 10)
306+ require.NoError(t, err)
307+ assert.Equal(t, 0, n)
308+
309+ // feat is 3 commits ahead of a: the raw count stops at the limit.
310+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 2)
311+ require.NoError(t, err)
312+ assert.Equal(t, 2, n)
313+ n, err = g.CountBehind(ctx, m.f.Name, m.a, m.featTip, 4)
314+ require.NoError(t, err)
315+ assert.Equal(t, 3, n)
316+}
A internal/adapters/gitcli/mergeability.go +87 -0
@@ -0,0 +1,87 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
17+// the object database and touches no ref or index. It prints the tree of the
18+// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
19+// Commits with no common history do not merge: not clean, without paths.
20+func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
21+ shared, err := g.sharesHistory(ctx, repo, base, head)
22+ if err != nil || !shared {
23+ return domain.MergeTreeResult{}, err
24+ }
25+ out, conflict, err := g.mergeTreeOutput(ctx, repo, base, head)
26+ if err != nil {
27+ return domain.MergeTreeResult{}, err
28+ }
29+ return parseMergeTree(out, conflict)
30+}
31+
32+// mergeTreeOutput runs merge-tree and returns what it printed; conflict is its
33+// exit status 1.
34+func (g *Git) mergeTreeOutput(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (out string, conflict bool, err error) {
35+ dir, err := g.repoDir(repo)
36+ if err != nil {
37+ return "", false, err
38+ }
39+ out, err = g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
40+ conflict = exitCode(err) == 1
41+ if err != nil && !conflict {
42+ return "", false, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
43+ }
44+ return out, conflict, nil
45+}
46+
47+// sharesHistory reports whether the commits have a merge base.
48+func (g *Git) sharesHistory(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (bool, error) {
49+ _, err := g.MergeBase(ctx, repo, a, b)
50+ if errors.Is(err, domain.ErrNotFound) {
51+ return false, nil
52+ }
53+ return err == nil, err
54+}
55+
56+// parseMergeTree reads the output of merge-tree: the tree, then on a conflict
57+// the NUL-separated paths, all of them.
58+func parseMergeTree(out string, conflict bool) (domain.MergeTreeResult, error) {
59+ fields := strings.Split(out, "\x00")
60+ tree, err := parseID(fields[0])
61+ if !conflict {
62+ return domain.MergeTreeResult{Tree: tree, Clean: true}, err
63+ }
64+ paths := fields[1:]
65+ if i := slices.Index(paths, ""); i >= 0 {
66+ paths = paths[:i]
67+ }
68+ return domain.MergeTreeResult{ConflictPaths: paths}, nil
69+}
70+
71+// CountBehind runs `git rev-list --count --max-count=<limit> <target> ^<head>`
72+// and returns the count git printed.
73+func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (int, error) {
74+ dir, err := g.repoDir(repo)
75+ if err != nil {
76+ return 0, err
77+ }
78+ out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(limit), target.String(), "^"+head.String(), "--")...)
79+ if err != nil {
80+ return 0, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
81+ }
82+ n, err := strconv.Atoi(strings.TrimSpace(out))
83+ if err != nil {
84+ return 0, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
85+ }
86+ return n, nil
87+}
M internal/adapters/kvstore/ci_tip.go +29 -0
@@ -89,3 +89,32 @@ func (c *CI) DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string)
8989 }
9090 return nil
9191 }
92+
93+// DeleteRefTipIf removes the record of a ref only when it still names sha.
94+func (c *CI) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error {
95+ for range updateAttemptsTip {
96+ it, ok := c.db.Get(ctx, refTipKey(repo, ref))
97+ if !ok {
98+ return nil
99+ }
100+ var pb storagepb.CIRefTip
101+ if err := proto.Unmarshal(it.Value, &pb); err != nil {
102+ return fmt.Errorf("decode ref tip: %w", err)
103+ }
104+ if pb.GetSha() != sha {
105+ return nil // a newer push owns the record
106+ }
107+ var b kv.Batch
108+ b.RequireVersion(refTipKey(repo, ref), it.Version)
109+ b.Delete(refTipKey(repo, ref))
110+ _, err := c.db.Apply(ctx, &b)
111+ if errors.Is(err, kv.ErrConflict) {
112+ continue
113+ }
114+ if err != nil {
115+ return fmt.Errorf("delete ref tip: %w", err)
116+ }
117+ return nil
118+ }
119+ return fmt.Errorf("delete ref tip: %w", domain.ErrConflict)
120+}
A internal/adapters/kvstore/ci_tip_test.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestDeleteRefTipIfOnlyRemovesTheMatchingRecord(t *testing.T) {
16+ ctx := context.Background()
17+ s := openStore(t, t.TempDir())
18+ n := name(t, "proj")
19+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
20+ ci := s.CI()
21+ ref := "refs/heads/feat"
22+
23+ require.NoError(t, ci.PutRefTip(ctx, domain.CIRefTip{Repo: n, Ref: ref, Sha: "aaa", Actor: "ada"}))
24+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "bbb"))
25+ got, err := ci.GetRefTip(ctx, n, ref)
26+ require.NoError(t, err)
27+ assert.Equal(t, "aaa", got.Sha, "a record of another object stays")
28+
29+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"))
30+ _, err = ci.GetRefTip(ctx, n, ref)
31+ assert.ErrorIs(t, err, domain.ErrNotFound)
32+
33+ require.NoError(t, ci.DeleteRefTipIf(ctx, n, ref, "aaa"), "a missing record is not an error")
34+}
M internal/adapters/kvstore/mergerequests.go +150 -5
@@ -17,6 +17,7 @@ import (
1717 kv "bnema.dev/ermite-kv"
1818 "github.com/bnema/zerowrap"
1919 "google.golang.org/protobuf/proto"
20+ "google.golang.org/protobuf/types/known/timestamppb"
2021
2122 storagepb "bnema.dev/ermite/gen/ermite/storage"
2223 "bnema.dev/ermite/internal/domain"
@@ -27,10 +28,11 @@ import (
2728 // ending in "/" never matches another repository; numbers and comment ids are
2829 // zero-padded so that keys sort numerically.
2930 const (
30- mrPrefix = "mr/" // mr/<repo>/<number:010d>
31- commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
32- readPrefix = "mrread/" // mrread/<repo>/<number:010d>
33- seqPrefix = "mrseq/" // mrseq/<repo>
31+ mrPrefix = "mr/" // mr/<repo>/<number:010d>
32+ commentPrefix = "mrc/" // mrc/<repo>/<number:010d>/<id:010d>
33+ readPrefix = "mrread/" // mrread/<repo>/<number:010d>
34+ seqPrefix = "mrseq/" // mrseq/<repo>
35+ mergePrefix = "mrmerge/" // mrmerge/<repo>/<number:010d>
3436 // The indexes of the open merge requests by source branch and by target
3537 // branch, which a push looks up (the values are not read). They are written in
3638 // the same batch as the merge request, so they are never out of step with it,
@@ -69,6 +71,9 @@ func commentKey(repo domain.RepoName, n, id int64) []byte {
6971 func readKey(repo domain.RepoName, n int64) []byte {
7072 return []byte(readPrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
7173 }
74+func mergeKey(repo domain.RepoName, n int64) []byte {
75+ return []byte(mergePrefix + repo.String() + "/" + fmt.Sprintf(numberFormat, n))
76+}
7277 func seqKey(repo domain.RepoName) []byte { return []byte(seqPrefix + repo.String()) }
7378
7479 // mrOpenRepoPrefix is where the open merge requests of a repository are indexed
@@ -175,6 +180,7 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
175180 for _, p := range []string{
176181 mrRepoPrefix(repo), commentPrefix + repo.String() + "/", readPrefix + repo.String() + "/",
177182 mrOpenRepoPrefix(repo), mrTargetPrefix + repo.String() + "/",
183+ mergePrefix + repo.String() + "/",
178184 } {
179185 for k := range snap.Prefix(ctx, []byte(p)) {
180186 keys = append(keys, slices.Clone(k))
@@ -469,6 +475,9 @@ func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) erro
469475 } else {
470476 indexOpen(&b, nil, mr)
471477 }
478+ if !mr.IsOpen() {
479+ b.Delete(mergeKey(mr.Repo, mr.Number))
480+ }
472481 if _, err := m.db.Apply(ctx, &b); err != nil {
473482 if errors.Is(err, kv.ErrConflict) {
474483 return domain.ErrConflict
@@ -579,6 +588,133 @@ func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, numbe
579588 return fmt.Errorf("record read position of %d in %q: %w", number, repo, domain.ErrConflict)
580589 }
581590
591+// GetMergeability returns the stored mergeability, or domain.ErrNotFound.
592+func (m *MergeRequests) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
593+ item, ok := m.db.Get(ctx, mergeKey(repo, number))
594+ if !ok {
595+ return domain.Mergeability{}, domain.ErrNotFound
596+ }
597+ return decodeMergeability(repo, number, item)
598+}
599+
600+// PutMergeability stores the mergeability in a batch that requires the merge
601+// request to be unchanged since it was read here and open, and the record to
602+// be absent (Version 0) or unchanged.
603+//
604+//hexcheck:ignore suspicious-business-logic-in-adapter version preconditions of a derived record: a storage concern, no policy
605+func (m *MergeRequests) PutMergeability(ctx context.Context, mg domain.Mergeability) error {
606+ val, err := encodeMergeability(mg)
607+ if err != nil {
608+ return err
609+ }
610+ for range createTries {
611+ mrItem, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number))
612+ if !ok {
613+ return domain.ErrNotFound
614+ }
615+ mr, err := decodeMergeRequest(mg.Repo, mrItem)
616+ if err != nil {
617+ return err
618+ }
619+ if !mr.IsOpen() {
620+ return domain.ErrNotFound
621+ }
622+ key := mergeKey(mg.Repo, mg.Number)
623+ var b kv.Batch
624+ b.RequireVersion(mrKey(mg.Repo, mg.Number), mrItem.Version)
625+ if mg.Version == 0 {
626+ b.RequireAbsent(key)
627+ } else {
628+ b.RequireVersion(key, mg.Version)
629+ }
630+ b.Put(key, val)
631+ _, err = m.db.Apply(ctx, &b)
632+ if errors.Is(err, kv.ErrConflict) {
633+ // Either the record changed (the caller's conflict) or the merge
634+ // request did (retry against it).
635+ if cur, ok := m.db.Get(ctx, mrKey(mg.Repo, mg.Number)); ok && cur.Version != mrItem.Version {
636+ continue
637+ }
638+ return domain.ErrConflict
639+ }
640+ if err != nil {
641+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, err)
642+ }
643+ return nil
644+ }
645+ return fmt.Errorf("store mergeability of %d in %q: %w", mg.Number, mg.Repo, domain.ErrConflict)
646+}
647+
648+// DeleteMergeability removes the stored mergeability; absent is fine.
649+func (m *MergeRequests) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
650+ var b kv.Batch
651+ b.Delete(mergeKey(repo, number))
652+ if _, err := m.db.Apply(ctx, &b); err != nil {
653+ return fmt.Errorf("delete mergeability of %d in %q: %w", number, repo, err)
654+ }
655+ return nil
656+}
657+
658+func encodeMergeability(m domain.Mergeability) ([]byte, error) {
659+ var state storagepb.MergeabilityState
660+ switch m.State {
661+ case domain.MergeabilityUpToDate:
662+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
663+ case domain.MergeabilityBehind:
664+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND
665+ case domain.MergeabilityConflict:
666+ state = storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
667+ default:
668+ return nil, fmt.Errorf("%w: mergeability state %q is not stored", domain.ErrInvalid, m.State)
669+ }
670+ return proto.Marshal(&storagepb.Mergeability{
671+ State: state,
672+ TargetId: m.TargetID.String(),
673+ HeadId: m.HeadID.String(),
674+ Behind: int32(m.Behind), //nolint:gosec // capped at MaxBehindCount
675+ BehindCapped: m.BehindCapped,
676+ ConflictPaths: m.ConflictPaths,
677+ ConflictPathsTruncated: m.ConflictPathsTruncated,
678+ CheckedAt: timestamppb.New(m.CheckedAt),
679+ })
680+}
681+
682+func decodeMergeability(repo domain.RepoName, number int64, item kv.Item) (domain.Mergeability, error) {
683+ var pb storagepb.Mergeability
684+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
685+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
686+ }
687+ state, ok := mergeabilityStates[pb.GetState()]
688+ if !ok {
689+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: unknown state %d: %w", number, repo, pb.GetState(), domain.ErrNotFound)
690+ }
691+ target, err := domain.ParseObjectID(pb.GetTargetId())
692+ if err != nil {
693+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
694+ }
695+ head, err := domain.ParseObjectID(pb.GetHeadId())
696+ if err != nil {
697+ return domain.Mergeability{}, fmt.Errorf("decode mergeability of %d in %q: %w", number, repo, err)
698+ }
699+ return domain.Mergeability{
700+ Repo: repo, Number: number, State: state,
701+ TargetID: target, HeadID: head,
702+ Behind: int(pb.GetBehind()), BehindCapped: pb.GetBehindCapped(),
703+ ConflictPaths: pb.GetConflictPaths(),
704+ ConflictPathsTruncated: pb.GetConflictPathsTruncated(),
705+ CheckedAt: pb.GetCheckedAt().AsTime(),
706+ Version: item.Version,
707+ }, nil
708+}
709+
710+// mergeabilityStates maps the stored states to the domain ones; CHECKING is
711+// never stored.
712+var mergeabilityStates = map[storagepb.MergeabilityState]domain.MergeabilityState{
713+ storagepb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE: domain.MergeabilityUpToDate,
714+ storagepb.MergeabilityState_MERGEABILITY_STATE_BEHIND: domain.MergeabilityBehind,
715+ storagepb.MergeabilityState_MERGEABILITY_STATE_CONFLICT: domain.MergeabilityConflict,
716+}
717+
582718 func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) {
583719 if mr.Repo.IsZero() {
584720 return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -735,6 +871,8 @@ func encodeMode(m domain.MergeMode) (storagepb.MergeMode, error) {
735871 return storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, nil
736872 case domain.MergeModeSquash:
737873 return storagepb.MergeMode_MERGE_MODE_SQUASH, nil
874+ case domain.MergeModeMerge:
875+ return storagepb.MergeMode_MERGE_MODE_MERGE, nil
738876 }
739877 return 0, fmt.Errorf("%w: merge mode %q", domain.ErrInvalid, m)
740878 }
@@ -746,6 +884,8 @@ func decodeMode(m storagepb.MergeMode) domain.MergeMode {
746884 return domain.MergeModeFastForward
747885 case storagepb.MergeMode_MERGE_MODE_SQUASH:
748886 return domain.MergeModeSquash
887+ case storagepb.MergeMode_MERGE_MODE_MERGE:
888+ return domain.MergeModeMerge
749889 }
750890 return ""
751891 }
@@ -773,7 +913,7 @@ func decodeSide(s storagepb.DiffSide) domain.DiffSide {
773913 }
774914
775915 func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) {
776- pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock}
916+ pb := &storagepb.RepoSettings{FailingChecksBlock: s.FailingChecksBlock, MergeModesV2: true}
777917 for _, m := range s.AllowedMergeModes {
778918 mode, err := encodeMode(m)
779919 if err != nil || mode == storagepb.MergeMode_MERGE_MODE_UNSPECIFIED {
@@ -810,5 +950,10 @@ func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings {
810950 if err != nil {
811951 return domain.DefaultRepoSettings()
812952 }
953+ // Settings stored before merge commits: exactly {fast-forward, squash} was
954+ // the old default, "everything allowed".
955+ if !pb.GetMergeModesV2() && slices.Equal(normalized, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}) {
956+ normalized = domain.MergeModes()
957+ }
813958 return domain.RepoSettings{AllowedMergeModes: normalized, FailingChecksBlock: pb.GetFailingChecksBlock(), Issues: decodeIssuesMode(pb.GetIssues())}
814959 }
M internal/adapters/kvstore/mergerequests_open_test.go +25 -0
@@ -284,3 +284,28 @@ func TestOpenIndexIsBackfilledOnce(t *testing.T) {
284284 assert.Equal(t, int64(5), five.Number)
285285 assert.Equal(t, []int64{1, 5}, bySource(t, m2, old, "feat/one"))
286286 }
287+
288+// Issues and merge requests share a sequence, but only merge requests belong
289+// to the indexes used by branch-move checks and mergeability rescans.
290+func TestMergeabilityIndexesExcludeIssueNumbers(t *testing.T) {
291+ ctx := context.Background()
292+ s, mrs, repo := repoWithMRs(t)
293+ issue, err := s.Issues().Create(ctx, newIssue(t, repo, "issue"))
294+ require.NoError(t, err)
295+ mr, err := mrs.Create(ctx, newMR(t, repo, "request"))
296+ require.NoError(t, err)
297+ require.Equal(t, issue.Number+1, mr.Number)
298+
299+ source, truncated, err := mrs.OpenBySource(ctx, repo, []string{"feat/x"}, 10)
300+ require.NoError(t, err)
301+ assert.False(t, truncated)
302+ assert.Equal(t, []int64{mr.Number}, source)
303+ target, truncated, err := mrs.OpenByTarget(ctx, repo, []string{"main"}, 10)
304+ require.NoError(t, err)
305+ assert.False(t, truncated)
306+ assert.Equal(t, []int64{mr.Number}, target)
307+ open, err := mrs.ListOpen(ctx, repo)
308+ require.NoError(t, err)
309+ require.Len(t, open, 1)
310+ assert.Equal(t, mr.Number, open[0].Number)
311+}
M internal/adapters/kvstore/mergerequests_test.go +117 -0
@@ -572,3 +572,120 @@ func TestNoteFieldsRoundTrip(t *testing.T) {
572572 assert.Equal(t, c, got)
573573 }
574574 }
575+
576+// TestStoredOldDefaultModesGainMerge: settings written before merge commits
577+// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
578+// the same set written by this build is an explicit choice and stays.
579+func TestStoredOldDefaultModesGainMerge(t *testing.T) {
580+ ctx := context.Background()
581+ dir := t.TempDir()
582+ s, err := kvstore.Open(ctx, dir)
583+ require.NoError(t, err)
584+ require.NoError(t, s.Close(ctx))
585+
586+ put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
587+ val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
588+ Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
589+ require.NoError(t, err)
590+ var b kv.Batch
591+ b.Put([]byte("repo/"+repo), val)
592+ _, err = db.Apply(ctx, &b)
593+ require.NoError(t, err)
594+ }
595+ db, err := kv.Open(ctx, dir, kv.Options{})
596+ require.NoError(t, err)
597+ put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
598+ put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
599+ require.NoError(t, db.Close(ctx))
600+
601+ s = openStore(t, dir)
602+ old, err := s.Get(ctx, name(t, "old"))
603+ require.NoError(t, err)
604+ assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
605+ single, err := s.Get(ctx, name(t, "single"))
606+ require.NoError(t, err)
607+ assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
608+
609+ // An explicit {ff, squash} written now is kept.
610+ old.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
611+ require.NoError(t, s.Update(ctx, old))
612+ again, err := s.Get(ctx, name(t, "old"))
613+ require.NoError(t, err)
614+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
615+}
616+
617+func mergeability(mr domain.MergeRequest) domain.Mergeability {
618+ return domain.Mergeability{
619+ Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
620+ TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
621+ ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
622+ }
623+}
624+
625+func TestPutMergeabilityCAS(t *testing.T) {
626+ ctx := context.Background()
627+ _, mrs, n := repoWithMRs(t)
628+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
629+ require.NoError(t, err)
630+
631+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
632+ assert.ErrorIs(t, err, domain.ErrNotFound)
633+
634+ m := mergeability(mr)
635+ require.NoError(t, mrs.PutMergeability(ctx, m))
636+ assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
637+
638+ got, err := mrs.GetMergeability(ctx, n, mr.Number)
639+ require.NoError(t, err)
640+ assert.NotZero(t, got.Version)
641+ m.Version = got.Version
642+ assert.Equal(t, m, got)
643+
644+ next := got
645+ next.State = domain.MergeabilityUpToDate
646+ require.NoError(t, mrs.PutMergeability(ctx, next))
647+ assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
648+
649+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
650+ require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
651+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
652+ assert.ErrorIs(t, err, domain.ErrNotFound)
653+
654+ missing := mergeability(mr)
655+ missing.Number = 99
656+ assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
657+
658+ checking := mergeability(mr)
659+ checking.State = domain.MergeabilityChecking
660+ assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
661+}
662+
663+func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
664+ for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
665+ ctx := context.Background()
666+ _, mrs, n := repoWithMRs(t)
667+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
668+ require.NoError(t, err)
669+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
670+
671+ cur, err := mrs.Get(ctx, n, mr.Number)
672+ require.NoError(t, err)
673+ cur.State = state
674+ require.NoError(t, mrs.Update(ctx, cur))
675+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
676+ assert.ErrorIs(t, err, domain.ErrNotFound, state)
677+
678+ assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
679+ }
680+}
681+
682+func TestDeleteWipesMergeability(t *testing.T) {
683+ ctx := context.Background()
684+ s, mrs, n := repoWithMRs(t)
685+ mr, err := mrs.Create(ctx, newMR(t, n, "t"))
686+ require.NoError(t, err)
687+ require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
688+ require.NoError(t, s.Delete(ctx, n))
689+ _, err = mrs.GetMergeability(ctx, n, mr.Number)
690+ assert.ErrorIs(t, err, domain.ErrNotFound)
691+}
M internal/adapters/rpc/api.go +1 -1
@@ -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", "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"}
38+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "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.
M internal/adapters/rpc/convert.go +7 -0
@@ -88,6 +88,8 @@ func mergeModeToPB(m domain.MergeMode) pb.MergeMode {
8888 return pb.MergeMode_MERGE_MODE_FAST_FORWARD
8989 case domain.MergeModeSquash:
9090 return pb.MergeMode_MERGE_MODE_SQUASH
91+ case domain.MergeModeMerge:
92+ return pb.MergeMode_MERGE_MODE_MERGE_COMMIT
9193 }
9294 return pb.MergeMode_MERGE_MODE_UNSPECIFIED
9395 }
@@ -102,6 +104,8 @@ func mergeModeFromPB(m pb.MergeMode) (domain.MergeMode, error) {
102104 return domain.MergeModeFastForward, nil
103105 case pb.MergeMode_MERGE_MODE_SQUASH:
104106 return domain.MergeModeSquash, nil
107+ case pb.MergeMode_MERGE_MODE_MERGE_COMMIT:
108+ return domain.MergeModeMerge, nil
105109 }
106110 return "", fmt.Errorf("%w: merge mode %v", domain.ErrInvalid, m)
107111 }
@@ -497,6 +501,9 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
497501 case domain.CommentResolved:
498502 out.Time = timestamp(ev.At)
499503 out.Kind = &pb.Event_CommentResolved{CommentResolved: &pb.CommentResolved{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
504+ case domain.MergeRequestMergeability:
505+ out.Time = timestamp(ev.At)
506+ out.Kind = &pb.Event_MergeRequestMergeability{MergeRequestMergeability: &pb.MergeRequestMergeability{Repo: ev.Repo.String(), Number: ev.Number, Mergeability: mergeabilityToPB(ev.Mergeability)}}
500507 case domain.RunUpdated:
501508 out.Time = timestamp(ev.At)
502509 out.Kind = runEventToPB(ev)
M internal/adapters/rpc/convert_test.go +23 -1
@@ -213,7 +213,7 @@ func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213213 t.Parallel()
214214 // A repository value without settings (never stored with them) reads as the defaults.
215215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
216+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218218
219219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
@@ -376,3 +376,25 @@ func TestIssueStateToPBNeverPending(t *testing.T) {
376376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378378 }
379+
380+func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381+ t.Parallel()
382+ repo := mustName(t, "proj")
383+ for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384+ mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385+ got := mergeRequestToPB(mr).GetMergeability()
386+ if !want {
387+ assert.Nil(t, got, state)
388+ continue
389+ }
390+ require.NotNil(t, got)
391+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392+ ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394+ }
395+ for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396+ got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397+ assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398+ assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399+ }
400+}
M internal/adapters/rpc/mergerequest.go +47 -3
@@ -115,8 +115,11 @@ func diffSideFromPB(s pb.DiffSide) (domain.DiffSide, error) {
115115 return "", fmt.Errorf("%w: diff side %v", domain.ErrInvalid, s)
116116 }
117117
118+// mergeRequestToPB maps a request. An open one carries the CHECKING
119+// mergeability: the server has no result in hand here; Get and List override
120+// it with the computed one.
118121 func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
119- return &pb.MergeRequest{
122+ out := &pb.MergeRequest{
120123 Repo: mr.Repo.String(),
121124 Number: mr.Number,
122125 Title: mr.Title.String(),
@@ -131,6 +134,47 @@ func mergeRequestToPB(mr domain.MergeRequest) *pb.MergeRequest {
131134 MergedCommitId: mr.MergedCommitID.String(),
132135 MergeMode: mergeModeToPB(mr.MergeMode),
133136 }
137+ if mr.IsOpen() {
138+ out.Mergeability = &pb.Mergeability{State: pb.MergeabilityState_MERGEABILITY_STATE_CHECKING}
139+ }
140+ return out
141+}
142+
143+// mergeRequestWithMergeabilityToPB is mergeRequestToPB with the mergeability of
144+// an open request; m is ignored for a request that is not open.
145+func mergeRequestWithMergeabilityToPB(mr domain.MergeRequest, m domain.Mergeability) *pb.MergeRequest {
146+ out := mergeRequestToPB(mr)
147+ if mr.IsOpen() && m.State != "" {
148+ out.Mergeability = mergeabilityToPB(m)
149+ }
150+ return out
151+}
152+
153+func mergeabilityStateToPB(s domain.MergeabilityState) pb.MergeabilityState {
154+ switch s {
155+ case domain.MergeabilityChecking:
156+ return pb.MergeabilityState_MERGEABILITY_STATE_CHECKING
157+ case domain.MergeabilityUpToDate:
158+ return pb.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE
159+ case domain.MergeabilityBehind:
160+ return pb.MergeabilityState_MERGEABILITY_STATE_BEHIND
161+ case domain.MergeabilityConflict:
162+ return pb.MergeabilityState_MERGEABILITY_STATE_CONFLICT
163+ }
164+ return pb.MergeabilityState_MERGEABILITY_STATE_UNSPECIFIED
165+}
166+
167+func mergeabilityToPB(m domain.Mergeability) *pb.Mergeability {
168+ return &pb.Mergeability{
169+ State: mergeabilityStateToPB(m.State),
170+ TargetId: m.TargetID.String(),
171+ HeadId: m.HeadID.String(),
172+ Behind: int32(m.Behind), //nolint:gosec // capped at domain.MaxBehindCount
173+ BehindCapped: m.BehindCapped,
174+ ConflictPaths: m.ConflictPaths,
175+ ConflictPathsTruncated: m.ConflictPathsTruncated,
176+ CheckedAt: timestamp(m.CheckedAt),
177+ }
134178 }
135179
136180 func commentToPB(c domain.Comment) *pb.Comment {
@@ -214,7 +258,7 @@ func (s *mergeRequestService) ListMergeRequests(ctx context.Context, req *connec
214258 }
215259 out := &pb.ListMergeRequestsResponse{NextPageToken: page.NextPageToken, MergeRequests: make([]*pb.MergeRequest, 0, len(page.MergeRequests))}
216260 for _, mr := range page.MergeRequests {
217- out.MergeRequests = append(out.MergeRequests, mergeRequestToPB(mr))
261+ out.MergeRequests = append(out.MergeRequests, mergeRequestWithMergeabilityToPB(mr, page.Mergeability[mr.Number]))
218262 }
219263 return connect.NewResponse(out), nil
220264 }
@@ -229,7 +273,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect.
229273 return nil, connectError(ctx, err)
230274 }
231275 out := &pb.GetMergeRequestResponse{
232- MergeRequest: mergeRequestToPB(d.MergeRequest),
276+ MergeRequest: mergeRequestWithMergeabilityToPB(d.MergeRequest, d.Mergeability),
233277 CommitsTruncated: d.CommitsTruncated,
234278 DiffBaseId: d.DiffBaseID.String(),
235279 Stats: diffStatsToPB(d.Stats),
M internal/adapters/rpc/mergerequest_test.go +1 -0
@@ -51,6 +51,7 @@ func TestMergeRequestServiceCreate(t *testing.T) {
5151 require.NoError(t, err)
5252 assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())
5353 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())
54+ assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState())
5455 })
5556 }
5657 }
M internal/adapters/web/events.go +6 -2
@@ -31,7 +31,7 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=summary|refs
3232 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3333 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>
3535 // GET /-/events?topic=repo:<name>&view=issues
3636 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
3737 // GET /-/events?topic=repo:<name>&view=actions
@@ -54,6 +54,10 @@ import (
5454 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5555 // <dl id="refs-kv">…</dl>, the facts of the rail
5656 // commits-head data: <div id="commits-head">N new commits — reload</div>
57+// mr-mergeability data: <div id="mr-mergeability">…</div>, on the merge request pages:
58+// the badge of the merge box (a slot like mr-notice), when the
59+// mergeability of the request shown settles or a push moves its
60+// source or target
5761 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5862 // something changed, with the link that reloads the page
5963 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -252,7 +256,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
252256 return
253257 }
254258
255- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job"), sha: sha}
259+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), target: q.Get("target"), job: q.Get("job"), sha: sha}
256260 if topic == ownerTopic {
257261 st.filter = q.Get("repo")
258262 }
M internal/adapters/web/fragments.go +58 -8
@@ -28,6 +28,7 @@ type liveStream struct {
2828 head string // commits: the commit listed first
2929 n string // mr: the number of the merge request shown; run: the run id
3030 job string // job: the id of the job shown
31+ target string // mr: the target branch of the merge request shown
3132 // sha is the commit of the run of the job shown, read once when the stream
3233 // is set up: the annotations link to files at it, and it never changes.
3334 sha string
@@ -60,7 +61,7 @@ func liveEvent(we ports.WatchEvent) bool {
6061 }
6162 switch we.Event.(type) {
6263 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
63- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
64+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
6465 domain.RunUpdated, domain.JobUpdated,
6566 domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved:
6667 return true
@@ -170,6 +171,11 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
170171 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
171172 case domain.MergeRequestReady:
172173 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
174+ case domain.MergeRequestMergeability:
175+ if l.view != "mr" || strconv.FormatInt(ev.Number, 10) != l.n {
176+ return nil, nil
177+ }
178+ return l.mrMergeability(ctx)
173179 case domain.CommentEdited:
174180 return l.mrNotice(ctx, ev.Number, "a note was edited — reload")
175181 case domain.CommentDeleted:
@@ -436,21 +442,65 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
436442 return nil, nil
437443 }
438444
439-// mrPushed announces a push to the source branch of the merge request shown.
445+// mrPushed announces a push to the source branch of the merge request shown,
446+// and has the badge follow a push to its source or its target.
440447 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
441- if l.ref == "" {
448+ n, err := strconv.ParseInt(l.n, 10, 64)
449+ if err != nil {
442450 return nil, nil
443451 }
452+ var out []fragment
453+ moved := false
444454 for _, u := range p.Updates {
445- if u.Ref == "refs/heads/"+l.ref {
446- n, err := strconv.ParseInt(l.n, 10, 64)
455+ switch {
456+ case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
457+ notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
447458 if err != nil {
448- return nil, nil
459+ return nil, err
449460 }
450- return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
461+ out = append(out, notice...)
462+ moved = true
463+ case l.target != "" && u.Ref == "refs/heads/"+l.target:
464+ moved = true
465+ }
466+ if moved && len(out) > 0 {
467+ break
451468 }
452469 }
453- return nil, nil
470+ if !moved {
471+ return nil, nil
472+ }
473+ badge, err := l.mrMergeability(ctx)
474+ return append(out, badge...), err
475+}
476+
477+// mrMergeability is the badge of the merge box, read again: the result for the
478+// tips as they are now, "checking" until the server has one (it then sends the
479+// MergeRequestMergeability that turns the badge). Nothing for a request that
480+// is not open.
481+func (l *liveStream) mrMergeability(ctx context.Context) ([]fragment, error) {
482+ if !SignedIn(ctx) || l.s.cfg.MergeRequests == nil {
483+ return nil, nil // the box is the owner's
484+ }
485+ name, err := domain.ParseRepoName(l.repo)
486+ if err != nil {
487+ return nil, nil
488+ }
489+ n, err := strconv.ParseInt(l.n, 10, 64)
490+ if err != nil {
491+ return nil, nil
492+ }
493+ m, err := l.s.cfg.MergeRequests.Mergeability(ctx, principal(ctx), name, n)
494+ switch {
495+ case errors.Is(err, domain.ErrNotFound):
496+ return gap, nil
497+ case err != nil:
498+ return nil, err
499+ case m.State == "":
500+ return nil, nil
501+ }
502+ f, err := renderFragment(ctx, "mr-mergeability", MRMergeabilityFragment(mergeabilityView(m, l.target)))
503+ return []fragment{f}, err
454504 }
455505
456506 func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
M internal/adapters/web/fragments.templ +1 -1
@@ -102,7 +102,7 @@ templ Live(l Layout) {
102102 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
105+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106106 gap -> ermiteLive.reload()"
107107 ></span>
108108 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +1 -0
@@ -33,6 +33,7 @@ func TestLiveSlotsAreStableStatusRegions(t *testing.T) {
3333 }{
3434 {"empty commits slot", LiveSlot("commits-head"), "commits-head", ""},
3535 {"empty mr slot", LiveSlot("mr-notice"), "mr-notice", ""},
36+ {"mergeability slot, as sent by the stream", MRMergeabilityFragment(MergeabilityView{State: "up_to_date", Label: "Ready to merge", FFOk: true, MergeOk: true}), "mr-mergeability", "Ready to merge"},
3637 {"commits banner", CommitsBanner("2 new commits — reload", "/x/commits/main"), "commits-head", "2 new commits — reload"},
3738 {"mr notice", MRNotice("a comment was added — reload", "/x/merge-requests/1"), "mr-notice", "a comment was added — reload"},
3839 } {
M internal/adapters/web/mr.go +156 -18
@@ -196,10 +196,38 @@ type MergeBox struct {
196196 // CanDeleteBranch offers to delete the source after the merge: it is not
197197 // the default branch. The server checks again, with the open requests.
198198 CanDeleteBranch bool
199+ // Mergeability is the live badge of the box and what the modes depend on.
200+ Mergeability MergeabilityView
201+ // Signed is true when a mode that creates a commit (squash, merge commit)
202+ // is offered; Fingerprint is then the one of the key that signs it, empty
203+ // when the server does not tell.
204+ Signed bool
205+ Fingerprint string
206+ // Error is why the last merge was refused, shown in the box.
207+ Error string
199208 }
200209
201-// ModeOption is a merge mode the repository allows.
202-type ModeOption struct{ Value, Label string }
210+// ModeOption is a merge mode the repository allows. Disabled says that the
211+// mode is not possible for the current state of the request, Reason why.
212+type ModeOption struct {
213+ Value, Label string
214+ Disabled bool
215+ Reason string
216+}
217+
218+// MergeabilityView is the mergeability of an open request, ready to show: the
219+// badge, and what the merge modes depend on (the flags are what the controls
220+// of the box read, live).
221+type MergeabilityView struct {
222+ State, Label, Detail string
223+ Paths []string
224+ // PathsNote says that more paths conflict than are listed.
225+ PathsNote string
226+ // FFOk and MergeOk say whether fast-forward, squash and merge commit are
227+ // possible; the reasons say why not.
228+ FFOk, MergeOk bool
229+ FFReason, MergeReason string
230+}
203231
204232 // SinceNav switches between the whole diff and the diff since the last read.
205233 type SinceNav struct {
@@ -233,6 +261,16 @@ type MRPage struct {
233261 MergedShort string
234262 MergedHref string
235263 MergeMode string
264+ // Deleting is the source branch that a merge just asked to delete: the
265+ // deletion follows the answer.
266+ Deleting string
267+ // MergeError is why a merge was refused when the page has no merge box
268+ // to show it in (the request is not open any more).
269+ MergeError string
270+ // Canonical is the address of the request, set when the page answers a
271+ // refused action from the address of that action: the script puts it in
272+ // the address bar, so that a reload does not post again.
273+ Canonical string
236274
237275 Since *SinceNav
238276 SinceNote string
@@ -346,10 +384,59 @@ func modeName(m domain.MergeMode) string {
346384 return "fast-forward"
347385 case domain.MergeModeSquash:
348386 return "squash"
387+ case domain.MergeModeMerge:
388+ return "merge commit"
349389 }
350390 return ""
351391 }
352392
393+// mergeabilityView converts the mergeability of a request into the badge and
394+// the flags of the controls of the merge box. A request without result is
395+// being checked.
396+func mergeabilityView(m domain.Mergeability, target string) MergeabilityView {
397+ v := MergeabilityView{State: string(domain.MergeabilityChecking), Label: "Checking…"}
398+ v.FFOk, v.FFReason = m.Allows(domain.MergeModeFastForward)
399+ v.MergeOk, v.MergeReason = m.Allows(domain.MergeModeSquash)
400+ target = visibleText(target)
401+ switch m.State {
402+ case domain.MergeabilityUpToDate:
403+ v.State, v.Label = string(m.State), "Ready to merge"
404+ case domain.MergeabilityBehind:
405+ v.State, v.Label, v.Detail = string(m.State), "Behind "+target+" by "+behindText(m), "merges without conflicts"
406+ case domain.MergeabilityConflict:
407+ v.State, v.Label, v.Detail = string(m.State), "Conflicts with "+target, "rebase needed"
408+ v.Paths, v.PathsNote = conflictList(m)
409+ }
410+ return v
411+}
412+
413+// behindText is "2 commits", "1 commit" or "1000+ commits".
414+func behindText(m domain.Mergeability) string {
415+ if m.Behind == 1 && !m.BehindCapped {
416+ return "1 commit"
417+ }
418+ n := itoa(m.Behind)
419+ if m.BehindCapped {
420+ n += "+"
421+ }
422+ return n + " commits"
423+}
424+
425+// conflictList is the conflicting paths to show, and the note that says what
426+// the list lacks.
427+func conflictList(m domain.Mergeability) (paths []string, note string) {
428+ for _, p := range m.ConflictPaths {
429+ paths = append(paths, visibleText(p))
430+ }
431+ switch {
432+ case len(paths) == 0:
433+ note = "no common history."
434+ case m.ConflictPathsTruncated:
435+ note = "more files conflict than are listed."
436+ }
437+ return paths, note
438+}
439+
353440 // mrRow converts a merge request of a listing.
354441 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
355442 row := MRRow{
@@ -540,6 +627,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
540627 p.MergedHref = repoHref(d.name(), "commit", m.MergedCommitID.String())
541628 p.MergeMode = modeName(m.MergeMode)
542629 }
630+ if signed && m.State == domain.MRStateMerged && r.URL.Query().Get("deleting") == "1" && d.hasBranch(m.Source) {
631+ p.Deleting = visibleText(m.Source)
632+ }
543633 for _, mode := range d.repo.Settings.AllowedMergeModes {
544634 if name := modeName(mode); name != "" {
545635 p.Modes = append(p.Modes, name)
@@ -624,11 +714,18 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
624714 p.Invisible = invisible
625715 p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from))
626716 if signed && open {
627- p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
717+ p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target))
718+ p.Box.Error = mergeErrorOf(ctx)
628719 if m.IsDraft() {
629720 p.Box.ReadyAction = mrHref(d.name(), n, "ready")
630721 }
631722 }
723+ if mergeErrorOf(ctx) != "" {
724+ p.Canonical = self
725+ }
726+ if p.Box == nil {
727+ p.MergeError = mergeErrorOf(ctx) // the request is not open any more
728+ }
632729 if canWrite {
633730 p.GeneralForm = &CommentForm{
634731 ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
@@ -652,7 +749,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
652749 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
653750 }
654751 p.Live = true
655- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
752+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target))
656753 l.HasRail = true
657754 l.MR = true
658755 return MergeRequest(l, p), http.StatusOK, nil
@@ -669,37 +766,78 @@ func (d *repoData) hasBranch(name string) bool {
669766 }
670767
671768 // mergeBox is what the owner can do with an open request.
672-func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string) *MergeBox {
769+func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox {
673770 b := &MergeBox{
674771 MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
675772 ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
676773 CanMarkRead: head != "" && head != lastRead,
677774 CanDeleteBranch: source != d.repo.DefaultBranch,
775+ Mergeability: mg,
776+ }
777+ var lines []string
778+ cmd := ""
779+ if head != "" {
780+ cmd = "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
678781 }
679- ff, sq := false, false
680782 for _, mode := range d.repo.Settings.AllowedMergeModes {
681783 switch mode {
682784 case domain.MergeModeFastForward:
683- ff = true
684- b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
785+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward", Disabled: !mg.FFOk, Reason: reasonIf(!mg.FFOk, mg.FFReason)})
786+ lines = append(lines, cmd)
685787 case domain.MergeModeSquash:
686- sq = true
687- b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
788+ b.Signed = true
789+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
790+ lines = append(lines, cmd+" --mode squash")
791+ case domain.MergeModeMerge:
792+ b.Signed = true
793+ b.Modes = append(b.Modes, ModeOption{Value: "merge", Label: "merge commit (signed)", Disabled: !mg.MergeOk, Reason: reasonIf(!mg.MergeOk, mg.MergeReason)})
794+ lines = append(lines, cmd+" --mode merge")
688795 }
689796 }
797+ if b.Signed && s.cfg.SigningKey != nil {
798+ b.Fingerprint = s.cfg.SigningKey.Info().Key.Fingerprint
799+ }
690800 if head != "" {
691- cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
692- b.CLI = Clone{ID: "cli-merge"}
693- if ff {
694- b.CLI.Lines = append(b.CLI.Lines, cmd)
695- }
696- if sq {
697- b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
698- }
801+ b.CLI = Clone{ID: "cli-merge", Lines: lines}
699802 }
700803 return b
701804 }
702805
806+func reasonIf(cond bool, reason string) string {
807+ if cond {
808+ return reason
809+ }
810+ return ""
811+}
812+
813+// firstModeOK is the index of the first mode that is possible, -1 for none.
814+func (b *MergeBox) firstModeOK() int {
815+ for i, m := range b.Modes {
816+ if !m.Disabled {
817+ return i
818+ }
819+ }
820+ return -1
821+}
822+
823+// Checked is the mode the form has checked: the first that is possible, else
824+// the first (it is disabled, and a live update may enable it).
825+func (b *MergeBox) Checked() int { return max(b.firstModeOK(), 0) }
826+
827+// CanMerge tells whether some mode is possible: the button is enabled.
828+func (b *MergeBox) CanMerge() bool { return b.firstModeOK() >= 0 }
829+
830+// MergeTitle is why the button is disabled, empty when it is not.
831+func (b *MergeBox) MergeTitle() string {
832+ if b.CanMerge() {
833+ return ""
834+ }
835+ if len(b.Modes) == 0 {
836+ return "the repository allows no merge mode"
837+ }
838+ return b.Modes[0].Reason
839+}
840+
703841 // shellWord quotes s for a shell unless it is made of safe characters, which
704842 // a repository name always is.
705843 func shellWord(s string) string {
M internal/adapters/web/mr.templ +92 -4
@@ -2,7 +2,10 @@
22
33 package web
44
5-import "strings"
5+import (
6+ "strconv"
7+ "strings"
8+)
69
710 // MRList is the list of the merge requests of a repository: the open ones,
811 // then the merged and closed ones.
@@ -138,6 +141,12 @@ templ MergeRequest(l Layout, p MRPage) {
138141 }
139142 </dl>
140143 </header>
144+ if p.Deleting != "" {
145+ <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
146+ }
147+ if p.MergeError != "" {
148+ <p class="d-err" role="alert">{ p.MergeError }</p>
149+ }
141150 @LiveSlot("mr-notice")
142151 if p.Desc != "" {
143152 <article class="mr-desc prose" aria-label="description" hx-ignore>
@@ -176,6 +185,9 @@ templ MergeRequest(l Layout, p MRPage) {
176185 <div
177186 id="diffs"
178187 data-mr
188+ if p.Canonical != "" {
189+ data-canonical={ p.Canonical }
190+ }
179191 if p.Both {
180192 data-diff={ p.View }
181193 } else {
@@ -446,8 +458,13 @@ templ mrBar(p MRPage) {
446458 // mergeMenu is the merge of an open request, for the owner: the form (it
447459 // carries the head that was read), or "Ready for review" for a draft. The
448460 // mode and the branch deletion have keys (1-3, D) while it is open.
461+//
462+// The badge is a live slot (mr-mergeability); the modes and the button read
463+// its data-* attributes (hx-live bindings), so that they follow the slot when
464+// the stream replaces its content. The server renders the same state in the
465+// attributes, for a page without scripts.
449466 templ mergeMenu(p MRPage) {
450- <details class="menu mmenu" id="merge-menu" data-state="open">
467+ <details class="menu mmenu" id="merge-menu" data-state="open" open?={ p.Box.Error != "" }>
451468 <summary class="btn primary" aria-keyshortcuts="Shift+M">
452469 if p.Box.ReadyAction != "" {
453470 Draft
@@ -469,19 +486,32 @@ templ mergeMenu(p MRPage) {
469486 <dt>into</dt>
470487 <dd>{ p.Target }</dd>
471488 </dl>
489+ if p.Box.Error != "" {
490+ <p class="d-err" role="alert">{ p.Box.Error }</p>
491+ }
472492 if p.Box.ReadyAction != "" {
473493 <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
474494 <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
475495 <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button>
476496 </form>
477497 } else if !p.Box.HeadGone {
498+ @mrMergeabilitySlot(p.Box.Mergeability)
478499 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
479500 <input type="hidden" name="head_id" value={ p.Box.Head }/>
480501 <fieldset class="d-modes">
481502 <legend class="sr">merge mode</legend>
482503 for i, m := range p.Box.Modes {
483504 <label>
484- <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/>
505+ <input
506+ type="radio"
507+ name="mode"
508+ value={ m.Value }
509+ checked?={ i == p.Box.Checked() }
510+ disabled?={ m.Disabled }
511+ title={ m.Reason }
512+ :disabled="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true"
513+ :title="this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason"
514+ />
485515 { m.Label }
486516 if i < 9 {
487517 <kbd aria-hidden="true">{ itoa(i + 1) }</kbd>
@@ -489,11 +519,28 @@ templ mergeMenu(p MRPage) {
489519 </label>
490520 }
491521 </fieldset>
522+ if p.Box.Signed {
523+ <p class="d-note" id="mr-signing">
524+ squash and merge commits are signed with ermite's own key (generated on first start).
525+ if p.Box.Fingerprint != "" {
526+ fingerprint <code>{ p.Box.Fingerprint }</code>
527+ }
528+ <code>ermite signing-key show</code> prints it.
529+ </p>
530+ }
492531 if p.Box.CanDeleteBranch {
493532 <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label>
494533 }
495534 <div class="d-btns">
496- <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button>
535+ <button
536+ type="submit"
537+ class="btn primary"
538+ data-default
539+ disabled?={ !p.Box.CanMerge() }
540+ title={ p.Box.MergeTitle() }
541+ :disabled="!this.form.querySelector('input[name=mode]:checked:not(:disabled)')"
542+ :title="(this.form.querySelector('input[name=mode]:checked') || {}).title || ''"
543+ >Merge into { p.Target }</button>
497544 </div>
498545 </form>
499546 }
@@ -501,6 +548,47 @@ templ mergeMenu(p MRPage) {
501548 </details>
502549 }
503550
551+// MRMergeability is the content of the mergeability slot: the badge, and the
552+// state the controls of the merge box read.
553+templ MRMergeability(v MergeabilityView) {
554+ <span
555+ data-mergeability
556+ data-state={ v.State }
557+ data-ff-ok={ strconv.FormatBool(v.FFOk) }
558+ data-merge-ok={ strconv.FormatBool(v.MergeOk) }
559+ data-ff-reason={ v.FFReason }
560+ data-merge-reason={ v.MergeReason }
561+ >
562+ <span class="badge" data-state={ v.State }>{ v.Label }</span>
563+ if v.Detail != "" {
564+ { " " }<span class="dim">{ v.Detail }</span>
565+ }
566+ </span>
567+ if len(v.Paths) > 0 {
568+ <ul class="mg-paths" aria-label="conflicting files">
569+ for _, path := range v.Paths {
570+ <li><code>{ path }</code></li>
571+ }
572+ </ul>
573+ }
574+ if v.PathsNote != "" {
575+ <p class="dim">{ v.PathsNote }</p>
576+ }
577+}
578+
579+// mrMergeabilitySlot is the status region of the badge, with its content: the
580+// stream replaces the children, never the element (see LiveSlot).
581+templ mrMergeabilitySlot(v MergeabilityView) {
582+ <div id="mr-mergeability" class="live-slot" role="status" data-slot>
583+ @MRMergeability(v)
584+ </div>
585+}
586+
587+// MRMergeabilityFragment is the slot as the stream sends it.
588+templ MRMergeabilityFragment(v MergeabilityView) {
589+ @mrMergeabilitySlot(v)
590+}
591+
504592 // moreMenu is what else the owner does with an open request: mark the head
505593 // read, close it, merge it from a terminal.
506594 templ moreMenu(p MRPage) {
M internal/adapters/web/mr_post.go +55 -3
@@ -60,6 +60,10 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6060 return
6161 }
6262 to, back, err := h(ctx, r)
63+ var re *rerender
64+ if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65+ return
66+ }
6367 if err != nil {
6468 s.actionFailed(ctx, w, r, err, back)
6569 return
@@ -68,6 +72,43 @@ func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to strin
6872 }
6973 }
7074
75+// rerender is the error of a merge the use case refused for a reason the
76+// owner reads on the page of the request: the page is shown again, with the
77+// status, and the reason in its merge box. Any other error keeps actionFailed.
78+type rerender struct {
79+ status int
80+ msg string
81+ err error
82+}
83+
84+func (e *rerender) Error() string { return e.err.Error() }
85+func (e *rerender) Unwrap() error { return e.err }
86+
87+type mergeErrorKey struct{}
88+
89+// withMergeError carries the reason of a refused merge to the page.
90+func withMergeError(ctx context.Context, msg string) context.Context {
91+ return context.WithValue(ctx, mergeErrorKey{}, msg)
92+}
93+
94+func mergeErrorOf(ctx context.Context) string {
95+ msg, _ := ctx.Value(mergeErrorKey{}).(string)
96+ return msg
97+}
98+
99+// rerenderMR writes the page of the request with the reason of the refusal.
100+// It reports false when the page cannot be built; the caller then answers as
101+// for any refused action.
102+func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103+ c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104+ if err != nil {
105+ return false
106+ }
107+ w.Header().Set("Cache-Control", "no-store")
108+ s.write(ctx, w, r, re.status, c)
109+ return true
110+}
111+
71112 // actionFailed answers a refused action.
72113 func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73114 // The words of the use case, for the rules the owner can fix.
@@ -322,8 +363,11 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
322363 mode = domain.MergeModeFastForward
323364 case "squash":
324365 mode = domain.MergeModeSquash
366+ case "merge":
367+ mode = domain.MergeModeMerge
325368 default:
326- return "", back, formError("mode must be fast-forward or squash")
369+ err := formError("mode must be fast-forward, squash or merge")
370+ return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
327371 }
328372 name, err := domain.ParseRepoName(repo)
329373 if err != nil {
@@ -333,11 +377,19 @@ func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string,
333377 if err != nil {
334378 return "", back, err
335379 }
336- if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
380+ res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
337381 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
338- }); err != nil {
382+ })
383+ switch {
384+ case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
385+ return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
386+ case err != nil:
339387 return "", back, err
340388 }
389+ if res.SourceDeleted {
390+ // The deletion follows the answer: the page says it is being done.
391+ return back + "?deleting=1", back, nil
392+ }
341393 return back, back, nil
342394 }
343395
M internal/adapters/web/mr_templ.go +1188 -740
@@ -10,7 +10,10 @@ package web
1010 import "github.com/a-h/templ"
1111 import templruntime "github.com/a-h/templ/runtime"
1212
13-import "strings"
13+import (
14+ "strconv"
15+ "strings"
16+)
1417
1518 // MRList is the list of the merge requests of a repository: the open ones,
1619 // then the merged and closed ones.
@@ -65,7 +68,7 @@ func MRList(l Layout, p MRListPage) templ.Component {
6568 var templ_7745c5c3_Var3 string
6669 templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Open)))
6770 if templ_7745c5c3_Err != nil {
68- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 26, Col: 78}
71+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 29, Col: 78}
6972 }
7073 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
7174 if templ_7745c5c3_Err != nil {
@@ -101,7 +104,7 @@ func MRList(l Layout, p MRListPage) templ.Component {
101104 var templ_7745c5c3_Var4 string
102105 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.OpenNote)
103106 if templ_7745c5c3_Err != nil {
104- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 32, Col: 32}
107+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 35, Col: 32}
105108 }
106109 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
107110 if templ_7745c5c3_Err != nil {
@@ -120,7 +123,7 @@ func MRList(l Layout, p MRListPage) templ.Component {
120123 var templ_7745c5c3_Var5 string
121124 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Done)))
122125 if templ_7745c5c3_Err != nil {
123- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 35, Col: 90}
126+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 38, Col: 90}
124127 }
125128 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
126129 if templ_7745c5c3_Err != nil {
@@ -161,7 +164,7 @@ func MRList(l Layout, p MRListPage) templ.Component {
161164 var templ_7745c5c3_Var6 templ.SafeURL
162165 templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Newest))
163166 if templ_7745c5c3_Err != nil {
164- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 43, Col: 50}
167+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 46, Col: 50}
165168 }
166169 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
167170 if templ_7745c5c3_Err != nil {
@@ -185,7 +188,7 @@ func MRList(l Layout, p MRListPage) templ.Component {
185188 var templ_7745c5c3_Var7 templ.SafeURL
186189 templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Older))
187190 if templ_7745c5c3_Err != nil {
188- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 48, Col: 49}
191+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 51, Col: 49}
189192 }
190193 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
191194 if templ_7745c5c3_Err != nil {
@@ -245,7 +248,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
245248 var templ_7745c5c3_Var9 string
246249 templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)
247250 if templ_7745c5c3_Err != nil {
248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 59, Col: 44}
251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 62, Col: 44}
249252 }
250253 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
251254 if templ_7745c5c3_Err != nil {
@@ -273,7 +276,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
273276 var templ_7745c5c3_Var10 string
274277 templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.State)
275278 if templ_7745c5c3_Err != nil {
276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 62, Col: 55}
279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 55}
277280 }
278281 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
279282 if templ_7745c5c3_Err != nil {
@@ -286,7 +289,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
286289 var templ_7745c5c3_Var11 string
287290 templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)
288291 if templ_7745c5c3_Err != nil {
289- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 63, Col: 37}
292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 37}
290293 }
291294 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
292295 if templ_7745c5c3_Err != nil {
@@ -299,7 +302,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
299302 var templ_7745c5c3_Var12 string
300303 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(m.State)
301304 if templ_7745c5c3_Err != nil {
302- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 64, Col: 35}
305+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 67, Col: 35}
303306 }
304307 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
305308 if templ_7745c5c3_Err != nil {
@@ -312,7 +315,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
312315 var templ_7745c5c3_Var13 templ.SafeURL
313316 templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))
314317 if templ_7745c5c3_Err != nil {
315- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 59}
318+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 68, Col: 59}
316319 }
317320 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
318321 if templ_7745c5c3_Err != nil {
@@ -325,7 +328,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
325328 var templ_7745c5c3_Var14 string
326329 templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
327330 if templ_7745c5c3_Err != nil {
328- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 71}
331+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 68, Col: 71}
329332 }
330333 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
331334 if templ_7745c5c3_Err != nil {
@@ -338,7 +341,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
338341 var templ_7745c5c3_Var15 string
339342 templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Branches)
340343 if templ_7745c5c3_Err != nil {
341- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 65, Col: 109}
344+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 68, Col: 109}
342345 }
343346 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
344347 if templ_7745c5c3_Err != nil {
@@ -351,7 +354,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
351354 var templ_7745c5c3_Var16 string
352355 templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author)
353356 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 66, Col: 38}
357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 69, Col: 38}
355358 }
356359 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
357360 if templ_7745c5c3_Err != nil {
@@ -369,7 +372,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
369372 var templ_7745c5c3_Var17 string
370373 templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
371374 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 69, Col: 33}
375+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 72, Col: 33}
373376 }
374377 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
375378 if templ_7745c5c3_Err != nil {
@@ -382,7 +385,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
382385 var templ_7745c5c3_Var18 string
383386 templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
384387 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 69, Col: 52}
388+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 72, Col: 52}
386389 }
387390 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
388391 if templ_7745c5c3_Err != nil {
@@ -395,7 +398,7 @@ func mrRows(rows []MRRow, label string) templ.Component {
395398 var templ_7745c5c3_Var19 string
396399 templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
397400 if templ_7745c5c3_Err != nil {
398- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 69, Col: 63}
401+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 72, Col: 63}
399402 }
400403 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
401404 if templ_7745c5c3_Err != nil {
@@ -453,7 +456,7 @@ func mrListRail(p MRListPage) templ.Component {
453456 var templ_7745c5c3_Var21 string
454457 templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(p.Default)
455458 if templ_7745c5c3_Err != nil {
456- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 94, Col: 51}
459+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 97, Col: 51}
457460 }
458461 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
459462 if templ_7745c5c3_Err != nil {
@@ -471,7 +474,7 @@ func mrListRail(p MRListPage) templ.Component {
471474 var templ_7745c5c3_Var22 templ.SafeURL
472475 templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(b.Href))
473476 if templ_7745c5c3_Err != nil {
474- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 97, Col: 36}
477+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 100, Col: 36}
475478 }
476479 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
477480 if templ_7745c5c3_Err != nil {
@@ -484,7 +487,7 @@ func mrListRail(p MRListPage) templ.Component {
484487 var templ_7745c5c3_Var23 string
485488 templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name)
486489 if templ_7745c5c3_Err != nil {
487- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 97, Col: 47}
490+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 100, Col: 47}
488491 }
489492 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
490493 if templ_7745c5c3_Err != nil {
@@ -560,7 +563,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
560563 var templ_7745c5c3_Var26 string
561564 templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number)
562565 if templ_7745c5c3_Err != nil {
563- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 113, Col: 36}
566+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 116, Col: 36}
564567 }
565568 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
566569 if templ_7745c5c3_Err != nil {
@@ -573,7 +576,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
573576 var templ_7745c5c3_Var27 string
574577 templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title)
575578 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 113, Col: 55}
579+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 116, Col: 55}
577580 }
578581 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
579582 if templ_7745c5c3_Err != nil {
@@ -586,7 +589,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
586589 var templ_7745c5c3_Var28 string
587590 templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.State)
588591 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 116, Col: 45}
592+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 45}
590593 }
591594 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
592595 if templ_7745c5c3_Err != nil {
@@ -599,7 +602,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
599602 var templ_7745c5c3_Var29 string
600603 templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(p.State)
601604 if templ_7745c5c3_Err != nil {
602- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 117, Col: 14}
605+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 120, Col: 14}
603606 }
604607 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
605608 if templ_7745c5c3_Err != nil {
@@ -613,7 +616,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
613616 var templ_7745c5c3_Var30 string
614617 templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
615618 if templ_7745c5c3_Err != nil {
616- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 14}
619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 122, Col: 14}
617620 }
618621 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
619622 if templ_7745c5c3_Err != nil {
@@ -626,7 +629,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
626629 var templ_7745c5c3_Var31 templ.SafeURL
627630 templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.MergedHref))
628631 if templ_7745c5c3_Err != nil {
629- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 53}
632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 122, Col: 53}
630633 }
631634 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
632635 if templ_7745c5c3_Err != nil {
@@ -639,7 +642,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
639642 var templ_7745c5c3_Var32 string
640643 templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(p.MergedShort)
641644 if templ_7745c5c3_Err != nil {
642- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 71}
645+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 122, Col: 71}
643646 }
644647 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
645648 if templ_7745c5c3_Err != nil {
@@ -652,7 +655,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
652655 var templ_7745c5c3_Var33 string
653656 templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + p.MergeMode)
654657 if templ_7745c5c3_Err != nil {
655- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 119, Col: 99}
658+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 122, Col: 99}
656659 }
657660 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
658661 if templ_7745c5c3_Err != nil {
@@ -670,7 +673,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
670673 var templ_7745c5c3_Var34 string
671674 templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(" → ")
672675 if templ_7745c5c3_Err != nil {
673- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 125, Col: 14}
676+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 128, Col: 14}
674677 }
675678 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
676679 if templ_7745c5c3_Err != nil {
@@ -687,7 +690,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
687690 var templ_7745c5c3_Var35 string
688691 templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(p.Opened.Name)
689692 if templ_7745c5c3_Err != nil {
690- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 130, Col: 20}
693+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 133, Col: 20}
691694 }
692695 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
693696 if templ_7745c5c3_Err != nil {
@@ -701,7 +704,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
701704 var templ_7745c5c3_Var36 string
702705 templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
703706 if templ_7745c5c3_Err != nil {
704- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 132, Col: 14}
707+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 135, Col: 14}
705708 }
706709 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
707710 if templ_7745c5c3_Err != nil {
@@ -714,7 +717,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
714717 var templ_7745c5c3_Var37 string
715718 templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Opened.WhenAt)
716719 if templ_7745c5c3_Err != nil {
717- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 132, Col: 48}
720+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 135, Col: 48}
718721 }
719722 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
720723 if templ_7745c5c3_Err != nil {
@@ -727,7 +730,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
727730 var templ_7745c5c3_Var38 string
728731 templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Opened.Abs)
729732 if templ_7745c5c3_Err != nil {
730- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 132, Col: 71}
733+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 135, Col: 71}
731734 }
732735 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
733736 if templ_7745c5c3_Err != nil {
@@ -740,7 +743,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
740743 var templ_7745c5c3_Var39 string
741744 templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(p.Opened.When)
742745 if templ_7745c5c3_Err != nil {
743- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 132, Col: 89}
746+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 135, Col: 89}
744747 }
745748 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
746749 if templ_7745c5c3_Err != nil {
@@ -763,7 +766,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
763766 var templ_7745c5c3_Var40 string
764767 templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.UpdatedAt)
765768 if templ_7745c5c3_Err != nil {
766- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 137, Col: 37}
769+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 140, Col: 37}
767770 }
768771 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
769772 if templ_7745c5c3_Err != nil {
@@ -776,7 +779,7 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
776779 var templ_7745c5c3_Var41 string
777780 templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(p.Updated)
778781 if templ_7745c5c3_Err != nil {
779- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 137, Col: 51}
782+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 140, Col: 51}
780783 }
781784 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
782785 if templ_7745c5c3_Err != nil {
@@ -791,16 +794,62 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
791794 if templ_7745c5c3_Err != nil {
792795 return templ_7745c5c3_Err
793796 }
797+ if p.Deleting != "" {
798+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<p class=\"note\" role=\"status\">merged. the branch ")
799+ if templ_7745c5c3_Err != nil {
800+ return templ_7745c5c3_Err
801+ }
802+ var templ_7745c5c3_Var42 string
803+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(p.Deleting)
804+ if templ_7745c5c3_Err != nil {
805+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 145, Col: 64}
806+ }
807+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
808+ if templ_7745c5c3_Err != nil {
809+ return templ_7745c5c3_Err
810+ }
811+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " is being deleted.</p>")
812+ if templ_7745c5c3_Err != nil {
813+ return templ_7745c5c3_Err
814+ }
815+ }
816+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ")
817+ if templ_7745c5c3_Err != nil {
818+ return templ_7745c5c3_Err
819+ }
820+ if p.MergeError != "" {
821+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<p class=\"d-err\" role=\"alert\">")
822+ if templ_7745c5c3_Err != nil {
823+ return templ_7745c5c3_Err
824+ }
825+ var templ_7745c5c3_Var43 string
826+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.MergeError)
827+ if templ_7745c5c3_Err != nil {
828+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 148, Col: 47}
829+ }
830+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
831+ if templ_7745c5c3_Err != nil {
832+ return templ_7745c5c3_Err
833+ }
834+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</p>")
835+ if templ_7745c5c3_Err != nil {
836+ return templ_7745c5c3_Err
837+ }
838+ }
839+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " ")
840+ if templ_7745c5c3_Err != nil {
841+ return templ_7745c5c3_Err
842+ }
794843 templ_7745c5c3_Err = LiveSlot("mr-notice").Render(ctx, templ_7745c5c3_Buffer)
795844 if templ_7745c5c3_Err != nil {
796845 return templ_7745c5c3_Err
797846 }
798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ")
847+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " ")
799848 if templ_7745c5c3_Err != nil {
800849 return templ_7745c5c3_Err
801850 }
802851 if p.Desc != "" {
803- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")
852+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<article class=\"mr-desc prose\" aria-label=\"description\" hx-ignore>")
804853 if templ_7745c5c3_Err != nil {
805854 return templ_7745c5c3_Err
806855 }
@@ -808,130 +857,130 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
808857 if templ_7745c5c3_Err != nil {
809858 return templ_7745c5c3_Err
810859 }
811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</article>")
860+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</article>")
812861 if templ_7745c5c3_Err != nil {
813862 return templ_7745c5c3_Err
814863 }
815864 }
816- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " ")
865+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " ")
817866 if templ_7745c5c3_Err != nil {
818867 return templ_7745c5c3_Err
819868 }
820869 if p.Since != nil {
821- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<nav class=\"since\" aria-label=\"Diff range\"><a href=\"")
870+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<nav class=\"since\" aria-label=\"Diff range\"><a href=\"")
822871 if templ_7745c5c3_Err != nil {
823872 return templ_7745c5c3_Err
824873 }
825- var templ_7745c5c3_Var42 templ.SafeURL
826- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.AllHref))
874+ var templ_7745c5c3_Var44 templ.SafeURL
875+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.AllHref))
827876 if templ_7745c5c3_Err != nil {
828- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 150, Col: 42}
877+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 159, Col: 42}
829878 }
830- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
879+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
831880 if templ_7745c5c3_Err != nil {
832881 return templ_7745c5c3_Err
833882 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\"")
883+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\"")
835884 if templ_7745c5c3_Err != nil {
836885 return templ_7745c5c3_Err
837886 }
838887 if !p.Since.Current {
839- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " aria-current=\"page\"")
888+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " aria-current=\"page\"")
840889 if templ_7745c5c3_Err != nil {
841890 return templ_7745c5c3_Err
842891 }
843892 }
844- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">all changes <span class=\"mute\">vs ")
893+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, ">all changes <span class=\"mute\">vs ")
845894 if templ_7745c5c3_Err != nil {
846895 return templ_7745c5c3_Err
847896 }
848- var templ_7745c5c3_Var43 string
849- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
897+ var templ_7745c5c3_Var45 string
898+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
850899 if templ_7745c5c3_Err != nil {
851- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 154, Col: 49}
900+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 163, Col: 49}
852901 }
853- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
902+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
854903 if templ_7745c5c3_Err != nil {
855904 return templ_7745c5c3_Err
856905 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></a> <a href=\"")
906+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></a> <a href=\"")
858907 if templ_7745c5c3_Err != nil {
859908 return templ_7745c5c3_Err
860909 }
861- var templ_7745c5c3_Var44 templ.SafeURL
862- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.SinceHref))
910+ var templ_7745c5c3_Var46 templ.SafeURL
911+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Since.SinceHref))
863912 if templ_7745c5c3_Err != nil {
864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 156, Col: 44}
913+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 165, Col: 44}
865914 }
866- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
915+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
867916 if templ_7745c5c3_Err != nil {
868917 return templ_7745c5c3_Err
869918 }
870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\"")
919+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\"")
871920 if templ_7745c5c3_Err != nil {
872921 return templ_7745c5c3_Err
873922 }
874923 if p.Since.Current {
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " aria-current=\"page\"")
924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " aria-current=\"page\"")
876925 if templ_7745c5c3_Err != nil {
877926 return templ_7745c5c3_Err
878927 }
879928 }
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">since your last read <span class=\"mute\">")
929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, ">since your last read <span class=\"mute\">")
881930 if templ_7745c5c3_Err != nil {
882931 return templ_7745c5c3_Err
883932 }
884- var templ_7745c5c3_Var45 string
885- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.ReadShort)
933+ var templ_7745c5c3_Var47 string
934+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.ReadShort)
886935 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 160, Col: 64}
936+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 169, Col: 64}
888937 }
889- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
938+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
890939 if templ_7745c5c3_Err != nil {
891940 return templ_7745c5c3_Err
892941 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " → ")
942+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " → ")
894943 if templ_7745c5c3_Err != nil {
895944 return templ_7745c5c3_Err
896945 }
897- var templ_7745c5c3_Var46 string
898- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.HeadShort)
946+ var templ_7745c5c3_Var48 string
947+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Since.HeadShort)
899948 if templ_7745c5c3_Err != nil {
900- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 160, Col: 90}
949+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 169, Col: 90}
901950 }
902- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
951+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
903952 if templ_7745c5c3_Err != nil {
904953 return templ_7745c5c3_Err
905954 }
906- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span></a></nav>")
955+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span></a></nav>")
907956 if templ_7745c5c3_Err != nil {
908957 return templ_7745c5c3_Err
909958 }
910959 }
911- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
960+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
912961 if templ_7745c5c3_Err != nil {
913962 return templ_7745c5c3_Err
914963 }
915964 if p.SinceNote != "" {
916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<p class=\"notice\">")
965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<p class=\"notice\">")
917966 if templ_7745c5c3_Err != nil {
918967 return templ_7745c5c3_Err
919968 }
920- var templ_7745c5c3_Var47 string
921- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(p.SinceNote)
969+ var templ_7745c5c3_Var49 string
970+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(p.SinceNote)
922971 if templ_7745c5c3_Err != nil {
923- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 164, Col: 34}
972+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 173, Col: 34}
924973 }
925- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
974+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
926975 if templ_7745c5c3_Err != nil {
927976 return templ_7745c5c3_Err
928977 }
929- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</p>")
978+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</p>")
930979 if templ_7745c5c3_Err != nil {
931980 return templ_7745c5c3_Err
932981 }
933982 }
934- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " ")
983+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " ")
935984 if templ_7745c5c3_Err != nil {
936985 return templ_7745c5c3_Err
937986 }
@@ -939,87 +988,106 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
939988 if templ_7745c5c3_Err != nil {
940989 return templ_7745c5c3_Err
941990 }
942- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " ")
991+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " ")
943992 if templ_7745c5c3_Err != nil {
944993 return templ_7745c5c3_Err
945994 }
946995 if p.Invisible {
947- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
996+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
948997 if templ_7745c5c3_Err != nil {
949998 return templ_7745c5c3_Err
950999 }
9511000 }
952- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ")
1001+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ")
9531002 if templ_7745c5c3_Err != nil {
9541003 return templ_7745c5c3_Err
9551004 }
9561005 if p.Note != "" {
957- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<p class=\"notice\">")
1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<p class=\"notice\">")
9581007 if templ_7745c5c3_Err != nil {
9591008 return templ_7745c5c3_Err
9601009 }
961- var templ_7745c5c3_Var48 string
962- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
1010+ var templ_7745c5c3_Var50 string
1011+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
9631012 if templ_7745c5c3_Err != nil {
964- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 174, Col: 29}
1013+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 183, Col: 29}
9651014 }
966- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1015+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
9671016 if templ_7745c5c3_Err != nil {
9681017 return templ_7745c5c3_Err
9691018 }
970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</p>")
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</p>")
9711020 if templ_7745c5c3_Err != nil {
9721021 return templ_7745c5c3_Err
9731022 }
9741023 }
975- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " <div id=\"diffs\" data-mr")
1024+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " <div id=\"diffs\" data-mr")
9761025 if templ_7745c5c3_Err != nil {
9771026 return templ_7745c5c3_Err
9781027 }
1028+ if p.Canonical != "" {
1029+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, " data-canonical=\"")
1030+ if templ_7745c5c3_Err != nil {
1031+ return templ_7745c5c3_Err
1032+ }
1033+ var templ_7745c5c3_Var51 string
1034+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Canonical)
1035+ if templ_7745c5c3_Err != nil {
1036+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 189, Col: 32}
1037+ }
1038+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
1039+ if templ_7745c5c3_Err != nil {
1040+ return templ_7745c5c3_Err
1041+ }
1042+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"")
1043+ if templ_7745c5c3_Err != nil {
1044+ return templ_7745c5c3_Err
1045+ }
1046+ }
9791047 if p.Both {
980- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " data-diff=\"")
1048+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " data-diff=\"")
9811049 if templ_7745c5c3_Err != nil {
9821050 return templ_7745c5c3_Err
9831051 }
984- var templ_7745c5c3_Var49 string
985- templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
1052+ var templ_7745c5c3_Var52 string
1053+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
9861054 if templ_7745c5c3_Err != nil {
987- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 180, Col: 22}
1055+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 192, Col: 22}
9881056 }
989- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
1057+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
9901058 if templ_7745c5c3_Err != nil {
9911059 return templ_7745c5c3_Err
9921060 }
993- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\"")
1061+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\"")
9941062 if templ_7745c5c3_Err != nil {
9951063 return templ_7745c5c3_Err
9961064 }
9971065 } else {
998- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " data-view=\"")
1066+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " data-view=\"")
9991067 if templ_7745c5c3_Err != nil {
10001068 return templ_7745c5c3_Err
10011069 }
1002- var templ_7745c5c3_Var50 string
1003- templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
1070+ var templ_7745c5c3_Var53 string
1071+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
10041072 if templ_7745c5c3_Err != nil {
1005- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 182, Col: 22}
1073+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 194, Col: 22}
10061074 }
1007- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
1075+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
10081076 if templ_7745c5c3_Err != nil {
10091077 return templ_7745c5c3_Err
10101078 }
1011- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\"")
1079+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\"")
10121080 if templ_7745c5c3_Err != nil {
10131081 return templ_7745c5c3_Err
10141082 }
10151083 }
10161084 if p.Both && !p.Pinned {
1017- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
1085+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
10181086 if templ_7745c5c3_Err != nil {
10191087 return templ_7745c5c3_Err
10201088 }
10211089 }
1022- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, ">")
1090+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, ">")
10231091 if templ_7745c5c3_Err != nil {
10241092 return templ_7745c5c3_Err
10251093 }
@@ -1030,38 +1098,38 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10301098 }
10311099 }
10321100 if len(p.Files) == 0 {
1033- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<p class=\"note\">no changed files.</p>")
1101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<p class=\"note\">no changed files.</p>")
10341102 if templ_7745c5c3_Err != nil {
10351103 return templ_7745c5c3_Err
10361104 }
10371105 }
1038- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</div><h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
1106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</div><h2 class=\"rule-h\" id=\"comments\"><span>Comments</span><span class=\"count\">")
10391107 if templ_7745c5c3_Err != nil {
10401108 return templ_7745c5c3_Err
10411109 }
1042- var templ_7745c5c3_Var51 string
1043- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
1110+ var templ_7745c5c3_Var54 string
1111+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NUnresolved))
10441112 if templ_7745c5c3_Err != nil {
1045- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 195, Col: 97}
1113+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 207, Col: 97}
10461114 }
1047- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1115+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
10481116 if templ_7745c5c3_Err != nil {
10491117 return templ_7745c5c3_Err
10501118 }
1051- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " open / ")
1119+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " open / ")
10521120 if templ_7745c5c3_Err != nil {
10531121 return templ_7745c5c3_Err
10541122 }
1055- var templ_7745c5c3_Var52 string
1056- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
1123+ var templ_7745c5c3_Var55 string
1124+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.NComments))
10571125 if templ_7745c5c3_Err != nil {
1058- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 195, Col: 126}
1126+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 207, Col: 126}
10591127 }
1060- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1128+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
10611129 if templ_7745c5c3_Err != nil {
10621130 return templ_7745c5c3_Err
10631131 }
1064- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span></h2>")
1132+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "</span></h2>")
10651133 if templ_7745c5c3_Err != nil {
10661134 return templ_7745c5c3_Err
10671135 }
@@ -1077,17 +1145,17 @@ func MergeRequest(l Layout, p MRPage) templ.Component {
10771145 return templ_7745c5c3_Err
10781146 }
10791147 }
1080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ")
1148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " ")
10811149 if templ_7745c5c3_Err != nil {
10821150 return templ_7745c5c3_Err
10831151 }
10841152 if p.NComments == 0 {
1085- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<p class=\"dim\">no comments yet.</p>")
1153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<p class=\"dim\">no comments yet.</p>")
10861154 if templ_7745c5c3_Err != nil {
10871155 return templ_7745c5c3_Err
10881156 }
10891157 }
1090- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " ")
1158+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, " ")
10911159 if templ_7745c5c3_Err != nil {
10921160 return templ_7745c5c3_Err
10931161 }
@@ -1123,49 +1191,49 @@ func branchLink(name, href string) templ.Component {
11231191 }()
11241192 }
11251193 ctx = templ.InitializeContext(ctx)
1126- templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1127- if templ_7745c5c3_Var53 == nil {
1128- templ_7745c5c3_Var53 = templ.NopComponent
1194+ templ_7745c5c3_Var56 := templ.GetChildren(ctx)
1195+ if templ_7745c5c3_Var56 == nil {
1196+ templ_7745c5c3_Var56 = templ.NopComponent
11291197 }
11301198 ctx = templ.ClearChildren(ctx)
11311199 if href != "" {
1132- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "<a href=\"")
1200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<a href=\"")
11331201 if templ_7745c5c3_Err != nil {
11341202 return templ_7745c5c3_Err
11351203 }
1136- var templ_7745c5c3_Var54 templ.SafeURL
1137- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
1204+ var templ_7745c5c3_Var57 templ.SafeURL
1205+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href))
11381206 if templ_7745c5c3_Err != nil {
1139- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 213, Col: 31}
1207+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 225, Col: 31}
11401208 }
1141- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1209+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
11421210 if templ_7745c5c3_Err != nil {
11431211 return templ_7745c5c3_Err
11441212 }
1145- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">")
1213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\">")
11461214 if templ_7745c5c3_Err != nil {
11471215 return templ_7745c5c3_Err
11481216 }
1149- var templ_7745c5c3_Var55 string
1150- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(name)
1217+ var templ_7745c5c3_Var58 string
1218+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(name)
11511219 if templ_7745c5c3_Err != nil {
1152- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 213, Col: 40}
1220+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 225, Col: 40}
11531221 }
1154- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1222+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
11551223 if templ_7745c5c3_Err != nil {
11561224 return templ_7745c5c3_Err
11571225 }
1158- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</a>")
1226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</a>")
11591227 if templ_7745c5c3_Err != nil {
11601228 return templ_7745c5c3_Err
11611229 }
11621230 } else {
1163- var templ_7745c5c3_Var56 string
1164- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(name)
1231+ var templ_7745c5c3_Var59 string
1232+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(name)
11651233 if templ_7745c5c3_Err != nil {
1166- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 215, Col: 8}
1234+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 227, Col: 8}
11671235 }
1168- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1236+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
11691237 if templ_7745c5c3_Err != nil {
11701238 return templ_7745c5c3_Err
11711239 }
@@ -1193,9 +1261,9 @@ func lineNotes(notes []CommentView, form *CommentForm, suffix string) templ.Comp
11931261 }()
11941262 }
11951263 ctx = templ.InitializeContext(ctx)
1196- templ_7745c5c3_Var57 := templ.GetChildren(ctx)
1197- if templ_7745c5c3_Var57 == nil {
1198- templ_7745c5c3_Var57 = templ.NopComponent
1264+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1265+ if templ_7745c5c3_Var60 == nil {
1266+ templ_7745c5c3_Var60 = templ.NopComponent
11991267 }
12001268 ctx = templ.ClearChildren(ctx)
12011269 for _, c := range notes {
@@ -1232,182 +1300,182 @@ func commentView(c CommentView, suffix string) templ.Component {
12321300 }()
12331301 }
12341302 ctx = templ.InitializeContext(ctx)
1235- templ_7745c5c3_Var58 := templ.GetChildren(ctx)
1236- if templ_7745c5c3_Var58 == nil {
1237- templ_7745c5c3_Var58 = templ.NopComponent
1303+ templ_7745c5c3_Var61 := templ.GetChildren(ctx)
1304+ if templ_7745c5c3_Var61 == nil {
1305+ templ_7745c5c3_Var61 = templ.NopComponent
12381306 }
12391307 ctx = templ.ClearChildren(ctx)
1240- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "<div class=\"cmt\" id=\"")
1308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<div class=\"cmt\" id=\"")
12411309 if templ_7745c5c3_Err != nil {
12421310 return templ_7745c5c3_Err
12431311 }
1244- var templ_7745c5c3_Var59 string
1245- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
1312+ var templ_7745c5c3_Var62 string
1313+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue("c" + c.ID + suffix)
12461314 if templ_7745c5c3_Err != nil {
1247- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 42}
1315+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 246, Col: 42}
12481316 }
1249- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
1317+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12501318 if templ_7745c5c3_Err != nil {
12511319 return templ_7745c5c3_Err
12521320 }
1253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\" data-resolved=\"")
1321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" data-resolved=\"")
12541322 if templ_7745c5c3_Err != nil {
12551323 return templ_7745c5c3_Err
12561324 }
1257- var templ_7745c5c3_Var60 string
1258- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
1325+ var templ_7745c5c3_Var63 string
1326+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolved))
12591327 if templ_7745c5c3_Err != nil {
1260- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 234, Col: 81}
1328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 246, Col: 81}
12611329 }
1262- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
12631331 if templ_7745c5c3_Err != nil {
12641332 return templ_7745c5c3_Err
12651333 }
1266- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\"")
1334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\"")
12671335 if templ_7745c5c3_Err != nil {
12681336 return templ_7745c5c3_Err
12691337 }
12701338 if c.Reply != nil {
1271- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " data-thread")
1339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " data-thread")
12721340 if templ_7745c5c3_Err != nil {
12731341 return templ_7745c5c3_Err
12741342 }
12751343 }
1276- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "><p class=\"c-head\"><span class=\"c-who\">")
1344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "><p class=\"c-head\"><span class=\"c-who\">")
12771345 if templ_7745c5c3_Err != nil {
12781346 return templ_7745c5c3_Err
12791347 }
1280- var templ_7745c5c3_Var61 string
1281- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1348+ var templ_7745c5c3_Var64 string
1349+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
12821350 if templ_7745c5c3_Err != nil {
1283- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 236, Col: 33}
1351+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 248, Col: 33}
12841352 }
1285- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61))
1353+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
12861354 if templ_7745c5c3_Err != nil {
12871355 return templ_7745c5c3_Err
12881356 }
1289- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</span> ")
1357+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span> ")
12901358 if templ_7745c5c3_Err != nil {
12911359 return templ_7745c5c3_Err
12921360 }
12931361 if c.WhenAt != "" {
1294- var templ_7745c5c3_Var62 string
1295- templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1362+ var templ_7745c5c3_Var65 string
1363+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
12961364 if templ_7745c5c3_Err != nil {
1297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 9}
1365+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 250, Col: 9}
12981366 }
1299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1367+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
13001368 if templ_7745c5c3_Err != nil {
13011369 return templ_7745c5c3_Err
13021370 }
1303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<time class=\"mute\" datetime=\"")
1371+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<time class=\"mute\" datetime=\"")
13041372 if templ_7745c5c3_Err != nil {
13051373 return templ_7745c5c3_Err
13061374 }
1307- var templ_7745c5c3_Var63 string
1308- templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1375+ var templ_7745c5c3_Var66 string
1376+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
13091377 if templ_7745c5c3_Err != nil {
1310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 49}
1378+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 250, Col: 49}
13111379 }
1312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
1380+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
13131381 if templ_7745c5c3_Err != nil {
13141382 return templ_7745c5c3_Err
13151383 }
1316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" title=\"")
1384+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" title=\"")
13171385 if templ_7745c5c3_Err != nil {
13181386 return templ_7745c5c3_Err
13191387 }
1320- var templ_7745c5c3_Var64 string
1321- templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
1388+ var templ_7745c5c3_Var67 string
1389+ templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Abs)
13221390 if templ_7745c5c3_Err != nil {
1323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 65}
1391+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 250, Col: 65}
13241392 }
1325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
1393+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
13261394 if templ_7745c5c3_Err != nil {
13271395 return templ_7745c5c3_Err
13281396 }
1329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\">")
1397+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">")
13301398 if templ_7745c5c3_Err != nil {
13311399 return templ_7745c5c3_Err
13321400 }
1333- var templ_7745c5c3_Var65 string
1334- templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1401+ var templ_7745c5c3_Var68 string
1402+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
13351403 if templ_7745c5c3_Err != nil {
1336- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 238, Col: 76}
1404+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 250, Col: 76}
13371405 }
1338- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1406+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
13391407 if templ_7745c5c3_Err != nil {
13401408 return templ_7745c5c3_Err
13411409 }
1342- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</time> ")
1410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</time> ")
13431411 if templ_7745c5c3_Err != nil {
13441412 return templ_7745c5c3_Err
13451413 }
13461414 }
13471415 if c.Resolved {
1348- var templ_7745c5c3_Var66 string
1349- templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1416+ var templ_7745c5c3_Var69 string
1417+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13501418 if templ_7745c5c3_Err != nil {
1351- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 241, Col: 9}
1419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 253, Col: 9}
13521420 }
1353- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1421+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
13541422 if templ_7745c5c3_Err != nil {
13551423 return templ_7745c5c3_Err
13561424 }
1357- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<span class=\"c-state\">resolved</span> ")
1425+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span class=\"c-state\">resolved</span> ")
13581426 if templ_7745c5c3_Err != nil {
13591427 return templ_7745c5c3_Err
13601428 }
13611429 }
13621430 if c.Outdated {
1363- var templ_7745c5c3_Var67 string
1364- templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1431+ var templ_7745c5c3_Var70 string
1432+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13651433 if templ_7745c5c3_Err != nil {
1366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 244, Col: 9}
1434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 256, Col: 9}
13671435 }
1368- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67))
1436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
13691437 if templ_7745c5c3_Err != nil {
13701438 return templ_7745c5c3_Err
13711439 }
1372- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
1440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<span class=\"c-state\" title=\"written on an earlier head; the line may have moved\">outdated</span> ")
13731441 if templ_7745c5c3_Err != nil {
13741442 return templ_7745c5c3_Err
13751443 }
13761444 }
13771445 if c.Where != "" {
1378- var templ_7745c5c3_Var68 string
1379- templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1446+ var templ_7745c5c3_Var71 string
1447+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
13801448 if templ_7745c5c3_Err != nil {
1381- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 9}
1449+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 259, Col: 9}
13821450 }
1383- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1451+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
13841452 if templ_7745c5c3_Err != nil {
13851453 return templ_7745c5c3_Err
13861454 }
1387- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<span class=\"c-where mute\">")
1455+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<span class=\"c-where mute\">")
13881456 if templ_7745c5c3_Err != nil {
13891457 return templ_7745c5c3_Err
13901458 }
1391- var templ_7745c5c3_Var69 string
1392- templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
1459+ var templ_7745c5c3_Var72 string
1460+ templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(c.Where)
13931461 if templ_7745c5c3_Err != nil {
1394- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 247, Col: 47}
1462+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 259, Col: 47}
13951463 }
1396- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1464+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72))
13971465 if templ_7745c5c3_Err != nil {
13981466 return templ_7745c5c3_Err
13991467 }
1400- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span>")
1468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span>")
14011469 if templ_7745c5c3_Err != nil {
14021470 return templ_7745c5c3_Err
14031471 }
14041472 }
1405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</p>")
1473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "</p>")
14061474 if templ_7745c5c3_Err != nil {
14071475 return templ_7745c5c3_Err
14081476 }
14091477 if len(c.Parts) > 0 {
1410- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<p class=\"c-text\">")
1478+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "<p class=\"c-text\">")
14111479 if templ_7745c5c3_Err != nil {
14121480 return templ_7745c5c3_Err
14131481 }
@@ -1415,24 +1483,24 @@ func commentView(c CommentView, suffix string) templ.Component {
14151483 if templ_7745c5c3_Err != nil {
14161484 return templ_7745c5c3_Err
14171485 }
1418- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "</p>")
1486+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</p>")
14191487 if templ_7745c5c3_Err != nil {
14201488 return templ_7745c5c3_Err
14211489 }
14221490 } else if c.Deleted {
14231491 if c.Verdict != "" {
1424- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<p class=\"mute\">text deleted</p>")
1492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "<p class=\"mute\">text deleted</p>")
14251493 if templ_7745c5c3_Err != nil {
14261494 return templ_7745c5c3_Err
14271495 }
14281496 } else {
1429- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<p class=\"mute\">deleted note</p>")
1497+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<p class=\"mute\">deleted note</p>")
14301498 if templ_7745c5c3_Err != nil {
14311499 return templ_7745c5c3_Err
14321500 }
14331501 }
14341502 } else {
1435- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "<div class=\"c-body prose\">")
1503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<div class=\"c-body prose\">")
14361504 if templ_7745c5c3_Err != nil {
14371505 return templ_7745c5c3_Err
14381506 }
@@ -1447,147 +1515,147 @@ func commentView(c CommentView, suffix string) templ.Component {
14471515 return templ_7745c5c3_Err
14481516 }
14491517 }
1450- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</div>")
1518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "</div>")
14511519 if templ_7745c5c3_Err != nil {
14521520 return templ_7745c5c3_Err
14531521 }
14541522 }
14551523 if c.Edited {
1456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<span class=\"mute\">edited</span> ")
1524+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "<span class=\"mute\">edited</span> ")
14571525 if templ_7745c5c3_Err != nil {
14581526 return templ_7745c5c3_Err
14591527 }
14601528 }
14611529 if c.Verdict != "" {
1462- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<span class=\"c-state\">")
1530+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<span class=\"c-state\">")
14631531 if templ_7745c5c3_Err != nil {
14641532 return templ_7745c5c3_Err
14651533 }
1466- var templ_7745c5c3_Var70 string
1467- templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
1534+ var templ_7745c5c3_Var73 string
1535+ templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(c.Verdict)
14681536 if templ_7745c5c3_Err != nil {
1469- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 271, Col: 36}
1537+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 36}
14701538 }
1471- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1539+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
14721540 if templ_7745c5c3_Err != nil {
14731541 return templ_7745c5c3_Err
14741542 }
1475- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</span> ")
1543+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</span> ")
14761544 if templ_7745c5c3_Err != nil {
14771545 return templ_7745c5c3_Err
14781546 }
14791547 }
14801548 if c.Range != "" {
1481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<span class=\"mute\">")
1549+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<span class=\"mute\">")
14821550 if templ_7745c5c3_Err != nil {
14831551 return templ_7745c5c3_Err
14841552 }
1485- var templ_7745c5c3_Var71 string
1486- templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
1553+ var templ_7745c5c3_Var74 string
1554+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(c.Range)
14871555 if templ_7745c5c3_Err != nil {
1488- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 274, Col: 31}
1556+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 286, Col: 31}
14891557 }
1490- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1558+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74))
14911559 if templ_7745c5c3_Err != nil {
14921560 return templ_7745c5c3_Err
14931561 }
1494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</span> ")
1562+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "</span> ")
14951563 if templ_7745c5c3_Err != nil {
14961564 return templ_7745c5c3_Err
14971565 }
14981566 }
14991567 if c.Quote != "" {
1500- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
1568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<button type=\"button\" class=\"btn\" data-js data-quote=\"")
15011569 if templ_7745c5c3_Err != nil {
15021570 return templ_7745c5c3_Err
15031571 }
1504- var templ_7745c5c3_Var72 string
1505- templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
1572+ var templ_7745c5c3_Var75 string
1573+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Quote)
15061574 if templ_7745c5c3_Err != nil {
1507- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 277, Col: 65}
1575+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 289, Col: 65}
15081576 }
1509- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
1577+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
15101578 if templ_7745c5c3_Err != nil {
15111579 return templ_7745c5c3_Err
15121580 }
1513- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "\">Quote</button> ")
1581+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">Quote</button> ")
15141582 if templ_7745c5c3_Err != nil {
15151583 return templ_7745c5c3_Err
15161584 }
15171585 }
15181586 if c.Edit != nil {
1519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<details><summary>Edit</summary><form method=\"post\" action=\"")
1587+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<details><summary>Edit</summary><form method=\"post\" action=\"")
15201588 if templ_7745c5c3_Err != nil {
15211589 return templ_7745c5c3_Err
15221590 }
1523- var templ_7745c5c3_Var73 templ.SafeURL
1524- templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
1591+ var templ_7745c5c3_Var76 templ.SafeURL
1592+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Edit.Action))
15251593 if templ_7745c5c3_Err != nil {
1526- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 92}
1594+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 92}
15271595 }
1528- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73))
1596+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
15291597 if templ_7745c5c3_Err != nil {
15301598 return templ_7745c5c3_Err
15311599 }
1532- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "\"><input type=\"hidden\" name=\"from\" value=\"")
1600+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "\"><input type=\"hidden\" name=\"from\" value=\"")
15331601 if templ_7745c5c3_Err != nil {
15341602 return templ_7745c5c3_Err
15351603 }
1536- var templ_7745c5c3_Var74 string
1537- templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
1604+ var templ_7745c5c3_Var77 string
1605+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Edit.From))
15381606 if templ_7745c5c3_Err != nil {
1539- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 153}
1607+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 153}
15401608 }
1541- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1609+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
15421610 if templ_7745c5c3_Err != nil {
15431611 return templ_7745c5c3_Err
15441612 }
1545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\"><textarea name=\"text\" maxlength=\"8000\">")
1613+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><textarea name=\"text\" maxlength=\"8000\">")
15461614 if templ_7745c5c3_Err != nil {
15471615 return templ_7745c5c3_Err
15481616 }
1549- var templ_7745c5c3_Var75 string
1550- templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
1617+ var templ_7745c5c3_Var78 string
1618+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(c.Edit.Text)
15511619 if templ_7745c5c3_Err != nil {
1552- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 280, Col: 209}
1620+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 292, Col: 209}
15531621 }
1554- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75))
1622+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
15551623 if templ_7745c5c3_Err != nil {
15561624 return templ_7745c5c3_Err
15571625 }
1558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
1626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</textarea><button class=\"btn\" type=\"submit\">Save</button></form></details> ")
15591627 if templ_7745c5c3_Err != nil {
15601628 return templ_7745c5c3_Err
15611629 }
15621630 }
15631631 if c.Delete != nil {
1564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "<details><summary>Delete</summary><form method=\"post\" action=\"")
1632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<details><summary>Delete</summary><form method=\"post\" action=\"")
15651633 if templ_7745c5c3_Err != nil {
15661634 return templ_7745c5c3_Err
15671635 }
1568- var templ_7745c5c3_Var76 templ.SafeURL
1569- templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
1636+ var templ_7745c5c3_Var79 templ.SafeURL
1637+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Delete.Action))
15701638 if templ_7745c5c3_Err != nil {
1571- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 96}
1639+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 96}
15721640 }
1573- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76))
1641+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79))
15741642 if templ_7745c5c3_Err != nil {
15751643 return templ_7745c5c3_Err
15761644 }
1577- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\"><input type=\"hidden\" name=\"from\" value=\"")
1645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"><input type=\"hidden\" name=\"from\" value=\"")
15781646 if templ_7745c5c3_Err != nil {
15791647 return templ_7745c5c3_Err
15801648 }
1581- var templ_7745c5c3_Var77 string
1582- templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
1649+ var templ_7745c5c3_Var80 string
1650+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Delete.From))
15831651 if templ_7745c5c3_Err != nil {
1584- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 283, Col: 159}
1652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 159}
15851653 }
1586- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var77)
1654+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
15871655 if templ_7745c5c3_Err != nil {
15881656 return templ_7745c5c3_Err
15891657 }
1590- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
1658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "\"><p>Delete this note?</p><button class=\"btn\" type=\"submit\">Confirm delete</button></form></details> ")
15911659 if templ_7745c5c3_Err != nil {
15921660 return templ_7745c5c3_Err
15931661 }
@@ -1599,7 +1667,7 @@ func commentView(c CommentView, suffix string) templ.Component {
15991667 }
16001668 }
16011669 if c.Reply != nil {
1602- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<details data-reply><summary>Reply</summary>")
1670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<details data-reply><summary>Reply</summary>")
16031671 if templ_7745c5c3_Err != nil {
16041672 return templ_7745c5c3_Err
16051673 }
@@ -1607,80 +1675,80 @@ func commentView(c CommentView, suffix string) templ.Component {
16071675 if templ_7745c5c3_Err != nil {
16081676 return templ_7745c5c3_Err
16091677 }
1610- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "</details> ")
1678+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</details> ")
16111679 if templ_7745c5c3_Err != nil {
16121680 return templ_7745c5c3_Err
16131681 }
16141682 }
16151683 if c.Resolve != nil {
1616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<form class=\"c-act\" method=\"post\" action=\"")
1684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<form class=\"c-act\" method=\"post\" action=\"")
16171685 if templ_7745c5c3_Err != nil {
16181686 return templ_7745c5c3_Err
16191687 }
1620- var templ_7745c5c3_Var78 templ.SafeURL
1621- templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
1688+ var templ_7745c5c3_Var81 templ.SafeURL
1689+ templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Resolve.Action))
16221690 if templ_7745c5c3_Err != nil {
1623- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 294, Col: 77}
1691+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 306, Col: 77}
16241692 }
1625- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1693+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
16261694 if templ_7745c5c3_Err != nil {
16271695 return templ_7745c5c3_Err
16281696 }
1629- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
1697+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\"><input type=\"hidden\" name=\"resolved\" value=\"")
16301698 if templ_7745c5c3_Err != nil {
16311699 return templ_7745c5c3_Err
16321700 }
1633- var templ_7745c5c3_Var79 string
1634- templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
1701+ var templ_7745c5c3_Var82 string
1702+ templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(boolText(c.Resolve.Value))
16351703 if templ_7745c5c3_Err != nil {
1636- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 295, Col: 74}
1704+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 307, Col: 74}
16371705 }
1638- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1706+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82)
16391707 if templ_7745c5c3_Err != nil {
16401708 return templ_7745c5c3_Err
16411709 }
1642- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\"> ")
1710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\"> ")
16431711 if templ_7745c5c3_Err != nil {
16441712 return templ_7745c5c3_Err
16451713 }
16461714 if c.Resolve.From > 0 {
1647- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<input type=\"hidden\" name=\"from\" value=\"")
1715+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<input type=\"hidden\" name=\"from\" value=\"")
16481716 if templ_7745c5c3_Err != nil {
16491717 return templ_7745c5c3_Err
16501718 }
1651- var templ_7745c5c3_Var80 string
1652- templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
1719+ var templ_7745c5c3_Var83 string
1720+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(c.Resolve.From))
16531721 if templ_7745c5c3_Err != nil {
1654- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 297, Col: 66}
1722+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 309, Col: 66}
16551723 }
1656- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80)
1724+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
16571725 if templ_7745c5c3_Err != nil {
16581726 return templ_7745c5c3_Err
16591727 }
1660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "\"> ")
1728+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\"> ")
16611729 if templ_7745c5c3_Err != nil {
16621730 return templ_7745c5c3_Err
16631731 }
16641732 }
1665- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<button type=\"submit\" class=\"btn\">")
1733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<button type=\"submit\" class=\"btn\">")
16661734 if templ_7745c5c3_Err != nil {
16671735 return templ_7745c5c3_Err
16681736 }
1669- var templ_7745c5c3_Var81 string
1670- templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
1737+ var templ_7745c5c3_Var84 string
1738+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(c.Resolve.Label)
16711739 if templ_7745c5c3_Err != nil {
1672- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 299, Col: 55}
1740+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 311, Col: 55}
16731741 }
1674- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81))
1742+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84))
16751743 if templ_7745c5c3_Err != nil {
16761744 return templ_7745c5c3_Err
16771745 }
1678- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</button></form>")
1746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</button></form>")
16791747 if templ_7745c5c3_Err != nil {
16801748 return templ_7745c5c3_Err
16811749 }
16821750 }
1683- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "</div>")
1751+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</div>")
16841752 if templ_7745c5c3_Err != nil {
16851753 return templ_7745c5c3_Err
16861754 }
@@ -1712,214 +1780,214 @@ func commentForm(f CommentForm, suffix string) templ.Component {
17121780 }()
17131781 }
17141782 ctx = templ.InitializeContext(ctx)
1715- templ_7745c5c3_Var82 := templ.GetChildren(ctx)
1716- if templ_7745c5c3_Var82 == nil {
1717- templ_7745c5c3_Var82 = templ.NopComponent
1783+ templ_7745c5c3_Var85 := templ.GetChildren(ctx)
1784+ if templ_7745c5c3_Var85 == nil {
1785+ templ_7745c5c3_Var85 = templ.NopComponent
17181786 }
17191787 ctx = templ.ClearChildren(ctx)
1720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "<form class=\"cform\" method=\"post\" action=\"")
1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<form class=\"cform\" method=\"post\" action=\"")
17211789 if templ_7745c5c3_Err != nil {
17221790 return templ_7745c5c3_Err
17231791 }
1724- var templ_7745c5c3_Var83 templ.SafeURL
1725- templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
1792+ var templ_7745c5c3_Var86 templ.SafeURL
1793+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Action))
17261794 if templ_7745c5c3_Err != nil {
1727- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 67}
1795+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 67}
17281796 }
1729- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83))
1797+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
17301798 if templ_7745c5c3_Err != nil {
17311799 return templ_7745c5c3_Err
17321800 }
1733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "\"><label for=\"")
1801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "\"><label for=\"")
17341802 if templ_7745c5c3_Err != nil {
17351803 return templ_7745c5c3_Err
17361804 }
1737- var templ_7745c5c3_Var84 string
1738- templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1805+ var templ_7745c5c3_Var87 string
1806+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
17391807 if templ_7745c5c3_Err != nil {
1740- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 28}
1808+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 28}
17411809 }
1742- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
1810+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
17431811 if templ_7745c5c3_Err != nil {
17441812 return templ_7745c5c3_Err
17451813 }
1746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "\">")
1814+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\">")
17471815 if templ_7745c5c3_Err != nil {
17481816 return templ_7745c5c3_Err
17491817 }
1750- var templ_7745c5c3_Var85 string
1751- templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
1818+ var templ_7745c5c3_Var88 string
1819+ templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
17521820 if templ_7745c5c3_Err != nil {
1753- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 315, Col: 40}
1821+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 40}
17541822 }
1755- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85))
1823+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
17561824 if templ_7745c5c3_Err != nil {
17571825 return templ_7745c5c3_Err
17581826 }
1759- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "</label> <textarea id=\"")
1827+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "</label> <textarea id=\"")
17601828 if templ_7745c5c3_Err != nil {
17611829 return templ_7745c5c3_Err
17621830 }
1763- var templ_7745c5c3_Var86 string
1764- templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
1831+ var templ_7745c5c3_Var89 string
1832+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID + suffix)
17651833 if templ_7745c5c3_Err != nil {
1766- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 317, Col: 21}
1834+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 329, Col: 21}
17671835 }
1768- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var86)
1836+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
17691837 if templ_7745c5c3_Err != nil {
17701838 return templ_7745c5c3_Err
17711839 }
1772- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "\" name=\"text\" rows=\"3\" maxlength=\"8000\"")
1840+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\" name=\"text\" rows=\"3\" maxlength=\"8000\"")
17731841 if templ_7745c5c3_Err != nil {
17741842 return templ_7745c5c3_Err
17751843 }
17761844 if f.Plain {
1777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, " required placeholder=\"plain text, 8000 characters at most\"")
1845+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, " required placeholder=\"plain text, 8000 characters at most\"")
17781846 if templ_7745c5c3_Err != nil {
17791847 return templ_7745c5c3_Err
17801848 }
17811849 } else {
1782- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, " placeholder=\"Markdown, 8000 characters at most\"")
1850+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, " placeholder=\"Markdown, 8000 characters at most\"")
17831851 if templ_7745c5c3_Err != nil {
17841852 return templ_7745c5c3_Err
17851853 }
17861854 }
17871855 if f.Focus {
1788- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, " autofocus")
1856+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, " autofocus")
17891857 if templ_7745c5c3_Err != nil {
17901858 return templ_7745c5c3_Err
17911859 }
17921860 }
1793- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "></textarea> ")
1861+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "></textarea> ")
17941862 if templ_7745c5c3_Err != nil {
17951863 return templ_7745c5c3_Err
17961864 }
17971865 if f.Path != "" {
1798- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "<input type=\"hidden\" name=\"path\" value=\"")
1866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<input type=\"hidden\" name=\"path\" value=\"")
17991867 if templ_7745c5c3_Err != nil {
18001868 return templ_7745c5c3_Err
18011869 }
1802- var templ_7745c5c3_Var87 string
1803- templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
1870+ var templ_7745c5c3_Var90 string
1871+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path)
18041872 if templ_7745c5c3_Err != nil {
1805- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 332, Col: 50}
1873+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 50}
18061874 }
1807- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var87)
1875+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
18081876 if templ_7745c5c3_Err != nil {
18091877 return templ_7745c5c3_Err
18101878 }
1811- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "\"> <input type=\"hidden\" name=\"line\" value=\"")
1879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\"> <input type=\"hidden\" name=\"line\" value=\"")
18121880 if templ_7745c5c3_Err != nil {
18131881 return templ_7745c5c3_Err
18141882 }
1815- var templ_7745c5c3_Var88 string
1816- templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
1883+ var templ_7745c5c3_Var91 string
1884+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Line)
18171885 if templ_7745c5c3_Err != nil {
1818- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 50}
1886+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 345, Col: 50}
18191887 }
1820- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var88)
1888+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
18211889 if templ_7745c5c3_Err != nil {
18221890 return templ_7745c5c3_Err
18231891 }
1824- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "\"> <input type=\"hidden\" name=\"side\" value=\"")
1892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "\"> <input type=\"hidden\" name=\"side\" value=\"")
18251893 if templ_7745c5c3_Err != nil {
18261894 return templ_7745c5c3_Err
18271895 }
1828- var templ_7745c5c3_Var89 string
1829- templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
1896+ var templ_7745c5c3_Var92 string
1897+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Side)
18301898 if templ_7745c5c3_Err != nil {
1831- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 50}
1899+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 346, Col: 50}
18321900 }
1833- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var89)
1901+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
18341902 if templ_7745c5c3_Err != nil {
18351903 return templ_7745c5c3_Err
18361904 }
1837- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
1905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "\"> <label>from line <input type=\"number\" name=\"start_line\" min=\"1\" value=\"")
18381906 if templ_7745c5c3_Err != nil {
18391907 return templ_7745c5c3_Err
18401908 }
1841- var templ_7745c5c3_Var90 string
1842- templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
1909+ var templ_7745c5c3_Var93 string
1910+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.StartLine)
18431911 if templ_7745c5c3_Err != nil {
1844- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 86}
1912+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 347, Col: 86}
18451913 }
1846- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var90)
1914+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
18471915 if templ_7745c5c3_Err != nil {
18481916 return templ_7745c5c3_Err
18491917 }
1850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "\"></label> ")
1918+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\"></label> ")
18511919 if templ_7745c5c3_Err != nil {
18521920 return templ_7745c5c3_Err
18531921 }
18541922 }
18551923 if f.ReplyTo != "" {
1856- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "<input type=\"hidden\" name=\"reply_to\" value=\"")
1924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "<input type=\"hidden\" name=\"reply_to\" value=\"")
18571925 if templ_7745c5c3_Err != nil {
18581926 return templ_7745c5c3_Err
18591927 }
1860- var templ_7745c5c3_Var91 string
1861- templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
1928+ var templ_7745c5c3_Var94 string
1929+ templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ReplyTo)
18621930 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 57}
1931+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 350, Col: 57}
18641932 }
1865- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var91)
1933+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var94)
18661934 if templ_7745c5c3_Err != nil {
18671935 return templ_7745c5c3_Err
18681936 }
1869- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "\"> ")
1937+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "\"> ")
18701938 if templ_7745c5c3_Err != nil {
18711939 return templ_7745c5c3_Err
18721940 }
18731941 }
18741942 if f.Path == "" && f.ReplyTo == "" && !f.Plain {
1875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")
1943+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "<fieldset><legend>Verdict</legend><label><input type=\"radio\" name=\"verdict\" value=\"\" checked>Note</label><label><input type=\"radio\" name=\"verdict\" value=\"approve\">Approve</label><label><input type=\"radio\" name=\"verdict\" value=\"changes_requested\">Request changes</label></fieldset>")
18761944 if templ_7745c5c3_Err != nil {
18771945 return templ_7745c5c3_Err
18781946 }
18791947 }
18801948 if f.From > 0 {
1881- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "<input type=\"hidden\" name=\"from\" value=\"")
1949+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "<input type=\"hidden\" name=\"from\" value=\"")
18821950 if templ_7745c5c3_Err != nil {
18831951 return templ_7745c5c3_Err
18841952 }
1885- var templ_7745c5c3_Var92 string
1886- templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
1953+ var templ_7745c5c3_Var95 string
1954+ templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(f.From))
18871955 if templ_7745c5c3_Err != nil {
1888- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 56}
1956+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 356, Col: 56}
18891957 }
1890- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var92)
1958+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var95)
18911959 if templ_7745c5c3_Err != nil {
18921960 return templ_7745c5c3_Err
18931961 }
1894- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "\">")
1962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "\">")
18951963 if templ_7745c5c3_Err != nil {
18961964 return templ_7745c5c3_Err
18971965 }
18981966 }
1899- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
1967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Comment</button> ")
19001968 if templ_7745c5c3_Err != nil {
19011969 return templ_7745c5c3_Err
19021970 }
19031971 if f.Cancel != "" {
1904- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "<a class=\"btn\" href=\"")
1972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "<a class=\"btn\" href=\"")
19051973 if templ_7745c5c3_Err != nil {
19061974 return templ_7745c5c3_Err
19071975 }
1908- var templ_7745c5c3_Var93 templ.SafeURL
1909- templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
1976+ var templ_7745c5c3_Var96 templ.SafeURL
1977+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(f.Cancel))
19101978 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 349, Col: 49}
1979+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 361, Col: 49}
19121980 }
1913- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1981+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
19141982 if templ_7745c5c3_Err != nil {
19151983 return templ_7745c5c3_Err
19161984 }
1917- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "\">Cancel</a>")
1985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "\">Cancel</a>")
19181986 if templ_7745c5c3_Err != nil {
19191987 return templ_7745c5c3_Err
19201988 }
19211989 }
1922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "</div></form>")
1990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</div></form>")
19231991 if templ_7745c5c3_Err != nil {
19241992 return templ_7745c5c3_Err
19251993 }
@@ -1943,25 +2011,25 @@ func mrRail(p MRPage) templ.Component {
19432011 }()
19442012 }
19452013 ctx = templ.InitializeContext(ctx)
1946- templ_7745c5c3_Var94 := templ.GetChildren(ctx)
1947- if templ_7745c5c3_Var94 == nil {
1948- templ_7745c5c3_Var94 = templ.NopComponent
2014+ templ_7745c5c3_Var97 := templ.GetChildren(ctx)
2015+ if templ_7745c5c3_Var97 == nil {
2016+ templ_7745c5c3_Var97 = templ.NopComponent
19492017 }
19502018 ctx = templ.ClearChildren(ctx)
1951- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
2019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<section aria-labelledby=\"r-files\" class=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
19522020 if templ_7745c5c3_Err != nil {
19532021 return templ_7745c5c3_Err
19542022 }
1955- var templ_7745c5c3_Var95 string
1956- templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
2023+ var templ_7745c5c3_Var98 string
2024+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
19572025 if templ_7745c5c3_Err != nil {
1958- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 358, Col: 49}
2026+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 370, Col: 49}
19592027 }
1960- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
2028+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
19612029 if templ_7745c5c3_Err != nil {
19622030 return templ_7745c5c3_Err
19632031 }
1964- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
2032+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</span><kbd aria-hidden=\"true\">g f</kbd> <button type=\"button\" class=\"btn icon fold\" data-js aria-expanded=\"true\" title=\"fold the list of files\" hx-on:click=\"ermiteMR.foldFiles()\"><span class=\"ico i-chevron\" aria-hidden=\"true\"></span><span class=\"sr\">fold the list of files</span></button></h2>")
19652033 if templ_7745c5c3_Err != nil {
19662034 return templ_7745c5c3_Err
19672035 }
@@ -1969,71 +2037,71 @@ func mrRail(p MRPage) templ.Component {
19692037 if templ_7745c5c3_Err != nil {
19702038 return templ_7745c5c3_Err
19712039 }
1972- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "</section>")
2040+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</section>")
19732041 if templ_7745c5c3_Err != nil {
19742042 return templ_7745c5c3_Err
19752043 }
19762044 if len(p.Verdicts) > 0 {
1977- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "<section><h2>Verdicts</h2><ul>")
2045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<section><h2>Verdicts</h2><ul>")
19782046 if templ_7745c5c3_Err != nil {
19792047 return templ_7745c5c3_Err
19802048 }
19812049 for _, v := range p.Verdicts {
1982- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "<li><a href=\"")
2050+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "<li><a href=\"")
19832051 if templ_7745c5c3_Err != nil {
19842052 return templ_7745c5c3_Err
19852053 }
1986- var templ_7745c5c3_Var96 templ.SafeURL
1987- templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
2054+ var templ_7745c5c3_Var99 templ.SafeURL
2055+ templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(v.Href))
19882056 if templ_7745c5c3_Err != nil {
1989- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 37}
2057+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 383, Col: 37}
19902058 }
1991- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
2059+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
19922060 if templ_7745c5c3_Err != nil {
19932061 return templ_7745c5c3_Err
19942062 }
1995- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "\">")
2063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "\">")
19962064 if templ_7745c5c3_Err != nil {
19972065 return templ_7745c5c3_Err
19982066 }
1999- var templ_7745c5c3_Var97 string
2000- templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
2067+ var templ_7745c5c3_Var100 string
2068+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(v.Author)
20012069 if templ_7745c5c3_Err != nil {
2002- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 50}
2070+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 383, Col: 50}
20032071 }
2004- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
2072+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
20052073 if templ_7745c5c3_Err != nil {
20062074 return templ_7745c5c3_Err
20072075 }
2008- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, ": ")
2076+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, ": ")
20092077 if templ_7745c5c3_Err != nil {
20102078 return templ_7745c5c3_Err
20112079 }
2012- var templ_7745c5c3_Var98 string
2013- templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
2080+ var templ_7745c5c3_Var101 string
2081+ templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
20142082 if templ_7745c5c3_Err != nil {
2015- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 371, Col: 63}
2083+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 383, Col: 63}
20162084 }
2017- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
2085+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
20182086 if templ_7745c5c3_Err != nil {
20192087 return templ_7745c5c3_Err
20202088 }
2021- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "</a> ")
2089+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "</a> ")
20222090 if templ_7745c5c3_Err != nil {
20232091 return templ_7745c5c3_Err
20242092 }
20252093 if v.Stale {
2026- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "<span class=\"mute\">(stale)</span>")
2094+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "<span class=\"mute\">(stale)</span>")
20272095 if templ_7745c5c3_Err != nil {
20282096 return templ_7745c5c3_Err
20292097 }
20302098 }
2031- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "</li>")
2099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</li>")
20322100 if templ_7745c5c3_Err != nil {
20332101 return templ_7745c5c3_Err
20342102 }
20352103 }
2036- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "</ul></section>")
2104+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</ul></section>")
20372105 if templ_7745c5c3_Err != nil {
20382106 return templ_7745c5c3_Err
20392107 }
@@ -2042,74 +2110,74 @@ func mrRail(p MRPage) templ.Component {
20422110 if templ_7745c5c3_Err != nil {
20432111 return templ_7745c5c3_Err
20442112 }
2045- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
2113+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<section aria-labelledby=\"r-commits\"><h2 id=\"r-commits\">Commits <span class=\"count\">")
20462114 if templ_7745c5c3_Err != nil {
20472115 return templ_7745c5c3_Err
20482116 }
2049- var templ_7745c5c3_Var99 string
2050- templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
2117+ var templ_7745c5c3_Var102 string
2118+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
20512119 if templ_7745c5c3_Err != nil {
2052- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 382, Col: 71}
2120+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 71}
20532121 }
2054- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
2122+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
20552123 if templ_7745c5c3_Err != nil {
20562124 return templ_7745c5c3_Err
20572125 }
2058- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "</span></h2><ul class=\"pcommits\">")
2126+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "</span></h2><ul class=\"pcommits\">")
20592127 if templ_7745c5c3_Err != nil {
20602128 return templ_7745c5c3_Err
20612129 }
20622130 for _, c := range p.Commits {
2063- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "<li")
2131+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<li")
20642132 if templ_7745c5c3_Err != nil {
20652133 return templ_7745c5c3_Err
20662134 }
20672135 if c.New {
2068- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, " data-new")
2136+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " data-new")
20692137 if templ_7745c5c3_Err != nil {
20702138 return templ_7745c5c3_Err
20712139 }
20722140 }
2073- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "><a href=\"")
2141+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "><a href=\"")
20742142 if templ_7745c5c3_Err != nil {
20752143 return templ_7745c5c3_Err
20762144 }
2077- var templ_7745c5c3_Var100 templ.SafeURL
2078- templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
2145+ var templ_7745c5c3_Var103 templ.SafeURL
2146+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
20792147 if templ_7745c5c3_Err != nil {
2080- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 36}
2148+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 398, Col: 36}
20812149 }
2082- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
2150+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
20832151 if templ_7745c5c3_Err != nil {
20842152 return templ_7745c5c3_Err
20852153 }
2086- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "\">")
2154+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "\">")
20872155 if templ_7745c5c3_Err != nil {
20882156 return templ_7745c5c3_Err
20892157 }
2090- var templ_7745c5c3_Var101 string
2091- templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2158+ var templ_7745c5c3_Var104 string
2159+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
20922160 if templ_7745c5c3_Err != nil {
2093- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 386, Col: 48}
2161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 398, Col: 48}
20942162 }
2095- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
2163+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
20962164 if templ_7745c5c3_Err != nil {
20972165 return templ_7745c5c3_Err
20982166 }
2099- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "</a> <span class=\"s\">")
2167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</a> <span class=\"s\">")
21002168 if templ_7745c5c3_Err != nil {
21012169 return templ_7745c5c3_Err
21022170 }
2103- var templ_7745c5c3_Var102 string
2104- templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2171+ var templ_7745c5c3_Var105 string
2172+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
21052173 if templ_7745c5c3_Err != nil {
2106- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 387, Col: 32}
2174+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 32}
21072175 }
2108- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
2176+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
21092177 if templ_7745c5c3_Err != nil {
21102178 return templ_7745c5c3_Err
21112179 }
2112- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "</span> ")
2180+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "</span> ")
21132181 if templ_7745c5c3_Err != nil {
21142182 return templ_7745c5c3_Err
21152183 }
@@ -2119,138 +2187,138 @@ func mrRail(p MRPage) templ.Component {
21192187 return templ_7745c5c3_Err
21202188 }
21212189 }
2122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "<span class=\"mute\">")
2190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<span class=\"mute\">")
21232191 if templ_7745c5c3_Err != nil {
21242192 return templ_7745c5c3_Err
21252193 }
2126- var templ_7745c5c3_Var103 string
2127- templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2194+ var templ_7745c5c3_Var106 string
2195+ templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
21282196 if templ_7745c5c3_Err != nil {
2129- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 392, Col: 16}
2197+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 404, Col: 16}
21302198 }
2131- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
2199+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
21322200 if templ_7745c5c3_Err != nil {
21332201 return templ_7745c5c3_Err
21342202 }
2135- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " ")
2203+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, " ")
21362204 if templ_7745c5c3_Err != nil {
21372205 return templ_7745c5c3_Err
21382206 }
21392207 if c.WhenAt != "" {
2140- var templ_7745c5c3_Var104 string
2141- templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2208+ var templ_7745c5c3_Var107 string
2209+ templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
21422210 if templ_7745c5c3_Err != nil {
2143- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 15}
2211+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 406, Col: 15}
21442212 }
2145- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2213+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
21462214 if templ_7745c5c3_Err != nil {
21472215 return templ_7745c5c3_Err
21482216 }
2149- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "<time datetime=\"")
2217+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "<time datetime=\"")
21502218 if templ_7745c5c3_Err != nil {
21512219 return templ_7745c5c3_Err
21522220 }
2153- var templ_7745c5c3_Var105 string
2154- templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2221+ var templ_7745c5c3_Var108 string
2222+ templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
21552223 if templ_7745c5c3_Err != nil {
2156- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 42}
2224+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 406, Col: 42}
21572225 }
2158- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var105)
2226+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var108)
21592227 if templ_7745c5c3_Err != nil {
21602228 return templ_7745c5c3_Err
21612229 }
2162- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "\">")
2230+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "\">")
21632231 if templ_7745c5c3_Err != nil {
21642232 return templ_7745c5c3_Err
21652233 }
2166- var templ_7745c5c3_Var106 string
2167- templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2234+ var templ_7745c5c3_Var109 string
2235+ templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
21682236 if templ_7745c5c3_Err != nil {
2169- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 53}
2237+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 406, Col: 53}
21702238 }
2171- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2239+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
21722240 if templ_7745c5c3_Err != nil {
21732241 return templ_7745c5c3_Err
21742242 }
2175- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "</time> ")
2243+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "</time> ")
21762244 if templ_7745c5c3_Err != nil {
21772245 return templ_7745c5c3_Err
21782246 }
21792247 }
21802248 if c.New {
2181- var templ_7745c5c3_Var107 string
2182- templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
2249+ var templ_7745c5c3_Var110 string
2250+ templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
21832251 if templ_7745c5c3_Err != nil {
2184- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 397, Col: 18}
2252+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 409, Col: 18}
21852253 }
2186- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107))
2254+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110))
21872255 if templ_7745c5c3_Err != nil {
21882256 return templ_7745c5c3_Err
21892257 }
21902258 }
2191- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "</span></li>")
2259+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "</span></li>")
21922260 if templ_7745c5c3_Err != nil {
21932261 return templ_7745c5c3_Err
21942262 }
21952263 }
2196- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "</ul>")
2264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</ul>")
21972265 if templ_7745c5c3_Err != nil {
21982266 return templ_7745c5c3_Err
21992267 }
22002268 if len(p.Commits) == 0 {
2201- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "<p class=\"dim\">no commit to merge.</p>")
2269+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<p class=\"dim\">no commit to merge.</p>")
22022270 if templ_7745c5c3_Err != nil {
22032271 return templ_7745c5c3_Err
22042272 }
22052273 }
22062274 if p.CommitsNote != "" {
2207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "<p class=\"note\">")
2275+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "<p class=\"note\">")
22082276 if templ_7745c5c3_Err != nil {
22092277 return templ_7745c5c3_Err
22102278 }
2211- var templ_7745c5c3_Var108 string
2212- templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
2279+ var templ_7745c5c3_Var111 string
2280+ templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
22132281 if templ_7745c5c3_Err != nil {
2214- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 407, Col: 34}
2282+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 419, Col: 34}
22152283 }
2216- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2284+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
22172285 if templ_7745c5c3_Err != nil {
22182286 return templ_7745c5c3_Err
22192287 }
2220- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "</p>")
2288+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</p>")
22212289 if templ_7745c5c3_Err != nil {
22222290 return templ_7745c5c3_Err
22232291 }
22242292 }
2225- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
2293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
22262294 if templ_7745c5c3_Err != nil {
22272295 return templ_7745c5c3_Err
22282296 }
2229- var templ_7745c5c3_Var109 string
2230- templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
2297+ var templ_7745c5c3_Var112 string
2298+ templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
22312299 if templ_7745c5c3_Err != nil {
2232- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 36}
2300+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 36}
22332301 }
2234- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109))
2302+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
22352303 if templ_7745c5c3_Err != nil {
22362304 return templ_7745c5c3_Err
22372305 }
2238- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "</dd><dt>checks</dt><dd>")
2306+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "</dd><dt>checks</dt><dd>")
22392307 if templ_7745c5c3_Err != nil {
22402308 return templ_7745c5c3_Err
22412309 }
22422310 if p.ChecksBlock {
2243- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "failing ones block the merge")
2311+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "failing ones block the merge")
22442312 if templ_7745c5c3_Err != nil {
22452313 return templ_7745c5c3_Err
22462314 }
22472315 } else {
2248- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "do not block the merge")
2316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "do not block the merge")
22492317 if templ_7745c5c3_Err != nil {
22502318 return templ_7745c5c3_Err
22512319 }
22522320 }
2253- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "</dd></dl></section>")
2321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "</dd></dl></section>")
22542322 if templ_7745c5c3_Err != nil {
22552323 return templ_7745c5c3_Err
22562324 }
@@ -2278,51 +2346,51 @@ func mrBar(p MRPage) templ.Component {
22782346 }()
22792347 }
22802348 ctx = templ.InitializeContext(ctx)
2281- templ_7745c5c3_Var110 := templ.GetChildren(ctx)
2282- if templ_7745c5c3_Var110 == nil {
2283- templ_7745c5c3_Var110 = templ.NopComponent
2349+ templ_7745c5c3_Var113 := templ.GetChildren(ctx)
2350+ if templ_7745c5c3_Var113 == nil {
2351+ templ_7745c5c3_Var113 = templ.NopComponent
22842352 }
22852353 ctx = templ.ClearChildren(ctx)
2286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
2354+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "<div class=\"diff-tools mr-bar\" role=\"group\" aria-label=\"Diff view and merge\"><span class=\"dim\">")
22872355 if templ_7745c5c3_Err != nil {
22882356 return templ_7745c5c3_Err
22892357 }
2290- var templ_7745c5c3_Var111 string
2291- templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2358+ var templ_7745c5c3_Var114 string
2359+ templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
22922360 if templ_7745c5c3_Err != nil {
2293- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 55}
2361+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 445, Col: 55}
22942362 }
2295- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2363+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
22962364 if templ_7745c5c3_Err != nil {
22972365 return templ_7745c5c3_Err
22982366 }
2299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, " · <span class=\"plus\">+")
2367+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, " · <span class=\"plus\">+")
23002368 if templ_7745c5c3_Err != nil {
23012369 return templ_7745c5c3_Err
23022370 }
2303- var templ_7745c5c3_Var112 string
2304- templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2371+ var templ_7745c5c3_Var115 string
2372+ templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
23052373 if templ_7745c5c3_Err != nil {
2306- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 95}
2374+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 445, Col: 95}
23072375 }
2308- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2376+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
23092377 if templ_7745c5c3_Err != nil {
23102378 return templ_7745c5c3_Err
23112379 }
2312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "</span> <span class=\"minus\">-")
2380+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "</span> <span class=\"minus\">-")
23132381 if templ_7745c5c3_Err != nil {
23142382 return templ_7745c5c3_Err
23152383 }
2316- var templ_7745c5c3_Var113 string
2317- templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2384+ var templ_7745c5c3_Var116 string
2385+ templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
23182386 if templ_7745c5c3_Err != nil {
2319- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 140}
2387+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 445, Col: 140}
23202388 }
2321- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2389+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
23222390 if templ_7745c5c3_Err != nil {
23232391 return templ_7745c5c3_Err
23242392 }
2325- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "</span></span>")
2393+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "</span></span>")
23262394 if templ_7745c5c3_Err != nil {
23272395 return templ_7745c5c3_Err
23282396 }
@@ -2338,7 +2406,7 @@ func mrBar(p MRPage) templ.Component {
23382406 if templ_7745c5c3_Err != nil {
23392407 return templ_7745c5c3_Err
23402408 }
2341- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "<span class=\"grow\"></span>")
2409+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "<span class=\"grow\"></span>")
23422410 if templ_7745c5c3_Err != nil {
23432411 return templ_7745c5c3_Err
23442412 }
@@ -2351,7 +2419,7 @@ func mrBar(p MRPage) templ.Component {
23512419 if templ_7745c5c3_Err != nil {
23522420 return templ_7745c5c3_Err
23532421 }
2354- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, " ")
2422+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, " ")
23552423 if templ_7745c5c3_Err != nil {
23562424 return templ_7745c5c3_Err
23572425 }
@@ -2360,7 +2428,7 @@ func mrBar(p MRPage) templ.Component {
23602428 return templ_7745c5c3_Err
23612429 }
23622430 }
2363- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "</div>")
2431+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "</div>")
23642432 if templ_7745c5c3_Err != nil {
23652433 return templ_7745c5c3_Err
23662434 }
@@ -2371,6 +2439,11 @@ func mrBar(p MRPage) templ.Component {
23712439 // mergeMenu is the merge of an open request, for the owner: the form (it
23722440 // carries the head that was read), or "Ready for review" for a draft. The
23732441 // mode and the branch deletion have keys (1-3, D) while it is open.
2442+//
2443+// The badge is a live slot (mr-mergeability); the modes and the button read
2444+// its data-* attributes (hx-live bindings), so that they follow the slot when
2445+// the stream replaces its content. The server renders the same state in the
2446+// attributes, for a page without scripts.
23742447 func mergeMenu(p MRPage) templ.Component {
23752448 return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
23762449 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
@@ -2387,219 +2460,594 @@ func mergeMenu(p MRPage) templ.Component {
23872460 }()
23882461 }
23892462 ctx = templ.InitializeContext(ctx)
2390- templ_7745c5c3_Var114 := templ.GetChildren(ctx)
2391- if templ_7745c5c3_Var114 == nil {
2392- templ_7745c5c3_Var114 = templ.NopComponent
2463+ templ_7745c5c3_Var117 := templ.GetChildren(ctx)
2464+ if templ_7745c5c3_Var117 == nil {
2465+ templ_7745c5c3_Var117 = templ.NopComponent
23932466 }
23942467 ctx = templ.ClearChildren(ctx)
2395- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
2468+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "<details class=\"menu mmenu\" id=\"merge-menu\" data-state=\"open\"")
2469+ if templ_7745c5c3_Err != nil {
2470+ return templ_7745c5c3_Err
2471+ }
2472+ if p.Box.Error != "" {
2473+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, " open")
2474+ if templ_7745c5c3_Err != nil {
2475+ return templ_7745c5c3_Err
2476+ }
2477+ }
2478+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "><summary class=\"btn primary\" aria-keyshortcuts=\"Shift+M\">")
23962479 if templ_7745c5c3_Err != nil {
23972480 return templ_7745c5c3_Err
23982481 }
23992482 if p.Box.ReadyAction != "" {
2400- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "Draft ")
2483+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "Draft ")
24012484 if templ_7745c5c3_Err != nil {
24022485 return templ_7745c5c3_Err
24032486 }
24042487 } else {
2405- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "Merge ")
2488+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "Merge ")
24062489 if templ_7745c5c3_Err != nil {
24072490 return templ_7745c5c3_Err
24082491 }
24092492 }
2410- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
2493+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "<kbd aria-hidden=\"true\">M</kbd></summary><div class=\"menu-pop\" role=\"group\" aria-label=\"Merge\"><dl class=\"kv\"><dt>head</dt><dd>")
24112494 if templ_7745c5c3_Err != nil {
24122495 return templ_7745c5c3_Err
24132496 }
24142497 if p.Box.HeadGone {
2415- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 222, "gone: the source branch was deleted")
2498+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "gone: the source branch was deleted")
24162499 if templ_7745c5c3_Err != nil {
24172500 return templ_7745c5c3_Err
24182501 }
24192502 } else {
2420- var templ_7745c5c3_Var115 string
2421- templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
2503+ var templ_7745c5c3_Var118 string
2504+ templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
24222505 if templ_7745c5c3_Err != nil {
2423- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 466, Col: 19}
2506+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 19}
24242507 }
2425- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2508+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
24262509 if templ_7745c5c3_Err != nil {
24272510 return templ_7745c5c3_Err
24282511 }
24292512 }
2430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 223, "</dd><dt>into</dt><dd>")
2513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "</dd><dt>into</dt><dd>")
24312514 if templ_7745c5c3_Err != nil {
24322515 return templ_7745c5c3_Err
24332516 }
2434- var templ_7745c5c3_Var116 string
2435- templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2517+ var templ_7745c5c3_Var119 string
2518+ templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
24362519 if templ_7745c5c3_Err != nil {
2437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 470, Col: 18}
2520+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 18}
24382521 }
2439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
2522+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
24402523 if templ_7745c5c3_Err != nil {
24412524 return templ_7745c5c3_Err
24422525 }
2443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 224, "</dd></dl>")
2526+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "</dd></dl>")
24442527 if templ_7745c5c3_Err != nil {
24452528 return templ_7745c5c3_Err
24462529 }
2530+ if p.Box.Error != "" {
2531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<p class=\"d-err\" role=\"alert\">")
2532+ if templ_7745c5c3_Err != nil {
2533+ return templ_7745c5c3_Err
2534+ }
2535+ var templ_7745c5c3_Var120 string
2536+ templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.Error)
2537+ if templ_7745c5c3_Err != nil {
2538+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 490, Col: 47}
2539+ }
2540+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2541+ if templ_7745c5c3_Err != nil {
2542+ return templ_7745c5c3_Err
2543+ }
2544+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</p>")
2545+ if templ_7745c5c3_Err != nil {
2546+ return templ_7745c5c3_Err
2547+ }
2548+ }
24472549 if p.Box.ReadyAction != "" {
2448- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 225, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
2550+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "<p class=\"d-draft\">this is a draft: it cannot be merged until it is ready for review.</p><form class=\"d-btns\" method=\"post\" action=\"")
24492551 if templ_7745c5c3_Err != nil {
24502552 return templ_7745c5c3_Err
24512553 }
2452- var templ_7745c5c3_Var117 templ.SafeURL
2453- templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
2554+ var templ_7745c5c3_Var121 templ.SafeURL
2555+ templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadyAction))
24542556 if templ_7745c5c3_Err != nil {
2455- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 474, Col: 80}
2557+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 494, Col: 80}
24562558 }
2457- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2559+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
24582560 if templ_7745c5c3_Err != nil {
24592561 return templ_7745c5c3_Err
24602562 }
2461- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
2563+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "\"><button type=\"submit\" class=\"btn primary\" data-default title=\"remove the draft prefix from the title\">Ready for review</button></form>")
24622564 if templ_7745c5c3_Err != nil {
24632565 return templ_7745c5c3_Err
24642566 }
24652567 } else if !p.Box.HeadGone {
2466- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "<form class=\"d-acts\" method=\"post\" action=\"")
2568+ templ_7745c5c3_Err = mrMergeabilitySlot(p.Box.Mergeability).Render(ctx, templ_7745c5c3_Buffer)
24672569 if templ_7745c5c3_Err != nil {
24682570 return templ_7745c5c3_Err
24692571 }
2470- var templ_7745c5c3_Var118 templ.SafeURL
2471- templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2572+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, " <form class=\"d-acts\" method=\"post\" action=\"")
24722573 if templ_7745c5c3_Err != nil {
2473- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 478, Col: 80}
2574+ return templ_7745c5c3_Err
24742575 }
2475- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2576+ var templ_7745c5c3_Var122 templ.SafeURL
2577+ templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2578+ if templ_7745c5c3_Err != nil {
2579+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 499, Col: 80}
2580+ }
2581+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
24762582 if templ_7745c5c3_Err != nil {
24772583 return templ_7745c5c3_Err
24782584 }
2479- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
2585+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
24802586 if templ_7745c5c3_Err != nil {
24812587 return templ_7745c5c3_Err
24822588 }
2483- var templ_7745c5c3_Var119 string
2484- templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2589+ var templ_7745c5c3_Var123 string
2590+ templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
24852591 if templ_7745c5c3_Err != nil {
2486- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 59}
2592+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 500, Col: 59}
24872593 }
2488- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var119)
2594+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
24892595 if templ_7745c5c3_Err != nil {
24902596 return templ_7745c5c3_Err
24912597 }
2492- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
2598+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "\"><fieldset class=\"d-modes\"><legend class=\"sr\">merge mode</legend> ")
24932599 if templ_7745c5c3_Err != nil {
24942600 return templ_7745c5c3_Err
24952601 }
24962602 for i, m := range p.Box.Modes {
2497- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "<label><input type=\"radio\" name=\"mode\" value=\"")
2603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "<label><input type=\"radio\" name=\"mode\" value=\"")
24982604 if templ_7745c5c3_Err != nil {
24992605 return templ_7745c5c3_Err
25002606 }
2501- var templ_7745c5c3_Var120 string
2502- templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2607+ var templ_7745c5c3_Var124 string
2608+ templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
25032609 if templ_7745c5c3_Err != nil {
2504- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 55}
2610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 508, Col: 24}
25052611 }
2506- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var120)
2612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
25072613 if templ_7745c5c3_Err != nil {
25082614 return templ_7745c5c3_Err
25092615 }
2510- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "\"")
2616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "\"")
25112617 if templ_7745c5c3_Err != nil {
25122618 return templ_7745c5c3_Err
25132619 }
2514- if i == 0 {
2515- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, " checked")
2620+ if i == p.Box.Checked() {
2621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, " checked")
25162622 if templ_7745c5c3_Err != nil {
25172623 return templ_7745c5c3_Err
25182624 }
25192625 }
2520- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "> ")
2626+ if m.Disabled {
2627+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, " disabled")
2628+ if templ_7745c5c3_Err != nil {
2629+ return templ_7745c5c3_Err
2630+ }
2631+ }
2632+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, " title=\"")
25212633 if templ_7745c5c3_Err != nil {
25222634 return templ_7745c5c3_Err
25232635 }
2524- var templ_7745c5c3_Var121 string
2525- templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2636+ var templ_7745c5c3_Var125 string
2637+ templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Reason)
25262638 if templ_7745c5c3_Err != nil {
2527- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 485, Col: 17}
2639+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 25}
25282640 }
2529- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2641+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var125)
25302642 if templ_7745c5c3_Err != nil {
25312643 return templ_7745c5c3_Err
25322644 }
2533- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ")
2645+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\" :disabled=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffOk !== true : q('[data-mergeability]').data.mergeOk !== true\" :title=\"this.value === 'fast-forward' ? q('[data-mergeability]').data.ffReason : q('[data-mergeability]').data.mergeReason\"> ")
2646+ if templ_7745c5c3_Err != nil {
2647+ return templ_7745c5c3_Err
2648+ }
2649+ var templ_7745c5c3_Var126 string
2650+ templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2651+ if templ_7745c5c3_Err != nil {
2652+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 515, Col: 17}
2653+ }
2654+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2655+ if templ_7745c5c3_Err != nil {
2656+ return templ_7745c5c3_Err
2657+ }
2658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, " ")
25342659 if templ_7745c5c3_Err != nil {
25352660 return templ_7745c5c3_Err
25362661 }
25372662 if i < 9 {
2538- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "<kbd aria-hidden=\"true\">")
2663+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "<kbd aria-hidden=\"true\">")
25392664 if templ_7745c5c3_Err != nil {
25402665 return templ_7745c5c3_Err
25412666 }
2542- var templ_7745c5c3_Var122 string
2543- templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
2667+ var templ_7745c5c3_Var127 string
2668+ templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(i + 1))
25442669 if templ_7745c5c3_Err != nil {
2545- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 487, Col: 46}
2670+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 517, Col: 46}
25462671 }
2547- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122))
2672+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
25482673 if templ_7745c5c3_Err != nil {
25492674 return templ_7745c5c3_Err
25502675 }
2551- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "</kbd>")
2676+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "</kbd>")
25522677 if templ_7745c5c3_Err != nil {
25532678 return templ_7745c5c3_Err
25542679 }
25552680 }
2556- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "</label>")
2681+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</label>")
25572682 if templ_7745c5c3_Err != nil {
25582683 return templ_7745c5c3_Err
25592684 }
25602685 }
2561- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "</fieldset>")
2686+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</fieldset>")
25622687 if templ_7745c5c3_Err != nil {
25632688 return templ_7745c5c3_Err
25642689 }
2690+ if p.Box.Signed {
2691+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<p class=\"d-note\" id=\"mr-signing\">squash and merge commits are signed with ermite's own key (generated on first start). ")
2692+ if templ_7745c5c3_Err != nil {
2693+ return templ_7745c5c3_Err
2694+ }
2695+ if p.Box.Fingerprint != "" {
2696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "fingerprint <code>")
2697+ if templ_7745c5c3_Err != nil {
2698+ return templ_7745c5c3_Err
2699+ }
2700+ var templ_7745c5c3_Var128 string
2701+ templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(p.Box.Fingerprint)
2702+ if templ_7745c5c3_Err != nil {
2703+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 526, Col: 45}
2704+ }
2705+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
2706+ if templ_7745c5c3_Err != nil {
2707+ return templ_7745c5c3_Err
2708+ }
2709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</code> ")
2710+ if templ_7745c5c3_Err != nil {
2711+ return templ_7745c5c3_Err
2712+ }
2713+ }
2714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<code>ermite signing-key show</code> prints it.</p>")
2715+ if templ_7745c5c3_Err != nil {
2716+ return templ_7745c5c3_Err
2717+ }
2718+ }
25652719 if p.Box.CanDeleteBranch {
2566- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
2720+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<label class=\"d-check\"><input type=\"checkbox\" name=\"delete_branch\" value=\"1\"> delete ")
25672721 if templ_7745c5c3_Err != nil {
25682722 return templ_7745c5c3_Err
25692723 }
2570- var templ_7745c5c3_Var123 string
2571- templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2724+ var templ_7745c5c3_Var129 string
2725+ templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
25722726 if templ_7745c5c3_Err != nil {
2573- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 493, Col: 102}
2727+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 102}
25742728 }
2575- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123))
2729+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129))
25762730 if templ_7745c5c3_Err != nil {
25772731 return templ_7745c5c3_Err
25782732 }
2579- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
2733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, " after the merge <kbd aria-hidden=\"true\">D</kbd></label>")
25802734 if templ_7745c5c3_Err != nil {
25812735 return templ_7745c5c3_Err
25822736 }
25832737 }
2584- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default>Merge into ")
2738+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "<div class=\"d-btns\"><button type=\"submit\" class=\"btn primary\" data-default")
25852739 if templ_7745c5c3_Err != nil {
25862740 return templ_7745c5c3_Err
25872741 }
2588- var templ_7745c5c3_Var124 string
2589- templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2742+ if !p.Box.CanMerge() {
2743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, " disabled")
2744+ if templ_7745c5c3_Err != nil {
2745+ return templ_7745c5c3_Err
2746+ }
2747+ }
2748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, " title=\"")
2749+ if templ_7745c5c3_Err != nil {
2750+ return templ_7745c5c3_Err
2751+ }
2752+ var templ_7745c5c3_Var130 string
2753+ templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.MergeTitle())
25902754 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 82}
2755+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 540, Col: 33}
25922756 }
2593- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124))
2757+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var130)
25942758 if templ_7745c5c3_Err != nil {
25952759 return templ_7745c5c3_Err
25962760 }
2597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "</button></div></form>")
2761+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "\" :disabled=\"!this.form.querySelector('input[name=mode]:checked:not(:disabled)')\" :title=\"(this.form.querySelector('input[name=mode]:checked') || {}).title || ''\">Merge into ")
2762+ if templ_7745c5c3_Err != nil {
2763+ return templ_7745c5c3_Err
2764+ }
2765+ var templ_7745c5c3_Var131 string
2766+ templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2767+ if templ_7745c5c3_Err != nil {
2768+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 28}
2769+ }
2770+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
2771+ if templ_7745c5c3_Err != nil {
2772+ return templ_7745c5c3_Err
2773+ }
2774+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "</button></div></form>")
25982775 if templ_7745c5c3_Err != nil {
25992776 return templ_7745c5c3_Err
26002777 }
26012778 }
2602- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "</div></details>")
2779+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "</div></details>")
2780+ if templ_7745c5c3_Err != nil {
2781+ return templ_7745c5c3_Err
2782+ }
2783+ return nil
2784+ })
2785+}
2786+
2787+// MRMergeability is the content of the mergeability slot: the badge, and the
2788+// state the controls of the merge box read.
2789+func MRMergeability(v MergeabilityView) templ.Component {
2790+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2791+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
2792+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
2793+ return templ_7745c5c3_CtxErr
2794+ }
2795+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
2796+ if !templ_7745c5c3_IsBuffer {
2797+ defer func() {
2798+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
2799+ if templ_7745c5c3_Err == nil {
2800+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
2801+ }
2802+ }()
2803+ }
2804+ ctx = templ.InitializeContext(ctx)
2805+ templ_7745c5c3_Var132 := templ.GetChildren(ctx)
2806+ if templ_7745c5c3_Var132 == nil {
2807+ templ_7745c5c3_Var132 = templ.NopComponent
2808+ }
2809+ ctx = templ.ClearChildren(ctx)
2810+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "<span data-mergeability data-state=\"")
2811+ if templ_7745c5c3_Err != nil {
2812+ return templ_7745c5c3_Err
2813+ }
2814+ var templ_7745c5c3_Var133 string
2815+ templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
2816+ if templ_7745c5c3_Err != nil {
2817+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 556, Col: 22}
2818+ }
2819+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var133)
2820+ if templ_7745c5c3_Err != nil {
2821+ return templ_7745c5c3_Err
2822+ }
2823+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "\" data-ff-ok=\"")
2824+ if templ_7745c5c3_Err != nil {
2825+ return templ_7745c5c3_Err
2826+ }
2827+ var templ_7745c5c3_Var134 string
2828+ templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.FFOk))
2829+ if templ_7745c5c3_Err != nil {
2830+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 557, Col: 41}
2831+ }
2832+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var134)
2833+ if templ_7745c5c3_Err != nil {
2834+ return templ_7745c5c3_Err
2835+ }
2836+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "\" data-merge-ok=\"")
2837+ if templ_7745c5c3_Err != nil {
2838+ return templ_7745c5c3_Err
2839+ }
2840+ var templ_7745c5c3_Var135 string
2841+ templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.ResolveAttributeValue(strconv.FormatBool(v.MergeOk))
2842+ if templ_7745c5c3_Err != nil {
2843+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 558, Col: 47}
2844+ }
2845+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var135)
2846+ if templ_7745c5c3_Err != nil {
2847+ return templ_7745c5c3_Err
2848+ }
2849+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "\" data-ff-reason=\"")
2850+ if templ_7745c5c3_Err != nil {
2851+ return templ_7745c5c3_Err
2852+ }
2853+ var templ_7745c5c3_Var136 string
2854+ templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.FFReason)
2855+ if templ_7745c5c3_Err != nil {
2856+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 559, Col: 29}
2857+ }
2858+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var136)
2859+ if templ_7745c5c3_Err != nil {
2860+ return templ_7745c5c3_Err
2861+ }
2862+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\" data-merge-reason=\"")
2863+ if templ_7745c5c3_Err != nil {
2864+ return templ_7745c5c3_Err
2865+ }
2866+ var templ_7745c5c3_Var137 string
2867+ templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.MergeReason)
2868+ if templ_7745c5c3_Err != nil {
2869+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 560, Col: 35}
2870+ }
2871+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
2872+ if templ_7745c5c3_Err != nil {
2873+ return templ_7745c5c3_Err
2874+ }
2875+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "\"><span class=\"badge\" data-state=\"")
2876+ if templ_7745c5c3_Err != nil {
2877+ return templ_7745c5c3_Err
2878+ }
2879+ var templ_7745c5c3_Var138 string
2880+ templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.State)
2881+ if templ_7745c5c3_Err != nil {
2882+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 562, Col: 42}
2883+ }
2884+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var138)
2885+ if templ_7745c5c3_Err != nil {
2886+ return templ_7745c5c3_Err
2887+ }
2888+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "\">")
2889+ if templ_7745c5c3_Err != nil {
2890+ return templ_7745c5c3_Err
2891+ }
2892+ var templ_7745c5c3_Var139 string
2893+ templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(v.Label)
2894+ if templ_7745c5c3_Err != nil {
2895+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 562, Col: 54}
2896+ }
2897+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2898+ if templ_7745c5c3_Err != nil {
2899+ return templ_7745c5c3_Err
2900+ }
2901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</span> ")
2902+ if templ_7745c5c3_Err != nil {
2903+ return templ_7745c5c3_Err
2904+ }
2905+ if v.Detail != "" {
2906+ var templ_7745c5c3_Var140 string
2907+ templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
2908+ if templ_7745c5c3_Err != nil {
2909+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 564, Col: 8}
2910+ }
2911+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2912+ if templ_7745c5c3_Err != nil {
2913+ return templ_7745c5c3_Err
2914+ }
2915+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "<span class=\"dim\">")
2916+ if templ_7745c5c3_Err != nil {
2917+ return templ_7745c5c3_Err
2918+ }
2919+ var templ_7745c5c3_Var141 string
2920+ templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(v.Detail)
2921+ if templ_7745c5c3_Err != nil {
2922+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 564, Col: 38}
2923+ }
2924+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
2925+ if templ_7745c5c3_Err != nil {
2926+ return templ_7745c5c3_Err
2927+ }
2928+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</span>")
2929+ if templ_7745c5c3_Err != nil {
2930+ return templ_7745c5c3_Err
2931+ }
2932+ }
2933+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "</span> ")
2934+ if templ_7745c5c3_Err != nil {
2935+ return templ_7745c5c3_Err
2936+ }
2937+ if len(v.Paths) > 0 {
2938+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "<ul class=\"mg-paths\" aria-label=\"conflicting files\">")
2939+ if templ_7745c5c3_Err != nil {
2940+ return templ_7745c5c3_Err
2941+ }
2942+ for _, path := range v.Paths {
2943+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "<li><code>")
2944+ if templ_7745c5c3_Err != nil {
2945+ return templ_7745c5c3_Err
2946+ }
2947+ var templ_7745c5c3_Var142 string
2948+ templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(path)
2949+ if templ_7745c5c3_Err != nil {
2950+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 570, Col: 20}
2951+ }
2952+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142))
2953+ if templ_7745c5c3_Err != nil {
2954+ return templ_7745c5c3_Err
2955+ }
2956+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</code></li>")
2957+ if templ_7745c5c3_Err != nil {
2958+ return templ_7745c5c3_Err
2959+ }
2960+ }
2961+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "</ul>")
2962+ if templ_7745c5c3_Err != nil {
2963+ return templ_7745c5c3_Err
2964+ }
2965+ }
2966+ if v.PathsNote != "" {
2967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "<p class=\"dim\">")
2968+ if templ_7745c5c3_Err != nil {
2969+ return templ_7745c5c3_Err
2970+ }
2971+ var templ_7745c5c3_Var143 string
2972+ templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(v.PathsNote)
2973+ if templ_7745c5c3_Err != nil {
2974+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 575, Col: 30}
2975+ }
2976+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143))
2977+ if templ_7745c5c3_Err != nil {
2978+ return templ_7745c5c3_Err
2979+ }
2980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, "</p>")
2981+ if templ_7745c5c3_Err != nil {
2982+ return templ_7745c5c3_Err
2983+ }
2984+ }
2985+ return nil
2986+ })
2987+}
2988+
2989+// mrMergeabilitySlot is the status region of the badge, with its content: the
2990+// stream replaces the children, never the element (see LiveSlot).
2991+func mrMergeabilitySlot(v MergeabilityView) templ.Component {
2992+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
2993+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
2994+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
2995+ return templ_7745c5c3_CtxErr
2996+ }
2997+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
2998+ if !templ_7745c5c3_IsBuffer {
2999+ defer func() {
3000+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
3001+ if templ_7745c5c3_Err == nil {
3002+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
3003+ }
3004+ }()
3005+ }
3006+ ctx = templ.InitializeContext(ctx)
3007+ templ_7745c5c3_Var144 := templ.GetChildren(ctx)
3008+ if templ_7745c5c3_Var144 == nil {
3009+ templ_7745c5c3_Var144 = templ.NopComponent
3010+ }
3011+ ctx = templ.ClearChildren(ctx)
3012+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "<div id=\"mr-mergeability\" class=\"live-slot\" role=\"status\" data-slot>")
3013+ if templ_7745c5c3_Err != nil {
3014+ return templ_7745c5c3_Err
3015+ }
3016+ templ_7745c5c3_Err = MRMergeability(v).Render(ctx, templ_7745c5c3_Buffer)
3017+ if templ_7745c5c3_Err != nil {
3018+ return templ_7745c5c3_Err
3019+ }
3020+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "</div>")
3021+ if templ_7745c5c3_Err != nil {
3022+ return templ_7745c5c3_Err
3023+ }
3024+ return nil
3025+ })
3026+}
3027+
3028+// MRMergeabilityFragment is the slot as the stream sends it.
3029+func MRMergeabilityFragment(v MergeabilityView) templ.Component {
3030+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
3031+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
3032+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
3033+ return templ_7745c5c3_CtxErr
3034+ }
3035+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
3036+ if !templ_7745c5c3_IsBuffer {
3037+ defer func() {
3038+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
3039+ if templ_7745c5c3_Err == nil {
3040+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
3041+ }
3042+ }()
3043+ }
3044+ ctx = templ.InitializeContext(ctx)
3045+ templ_7745c5c3_Var145 := templ.GetChildren(ctx)
3046+ if templ_7745c5c3_Var145 == nil {
3047+ templ_7745c5c3_Var145 = templ.NopComponent
3048+ }
3049+ ctx = templ.ClearChildren(ctx)
3050+ templ_7745c5c3_Err = mrMergeabilitySlot(v).Render(ctx, templ_7745c5c3_Buffer)
26033051 if templ_7745c5c3_Err != nil {
26043052 return templ_7745c5c3_Err
26053053 }
@@ -2625,66 +3073,66 @@ func moreMenu(p MRPage) templ.Component {
26253073 }()
26263074 }
26273075 ctx = templ.InitializeContext(ctx)
2628- templ_7745c5c3_Var125 := templ.GetChildren(ctx)
2629- if templ_7745c5c3_Var125 == nil {
2630- templ_7745c5c3_Var125 = templ.NopComponent
3076+ templ_7745c5c3_Var146 := templ.GetChildren(ctx)
3077+ if templ_7745c5c3_Var146 == nil {
3078+ templ_7745c5c3_Var146 = templ.NopComponent
26313079 }
26323080 ctx = templ.ClearChildren(ctx)
2633- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
3081+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "<details class=\"menu omenu\"><summary class=\"btn icon\" aria-label=\"More actions\" title=\"more actions\">⋯</summary><div class=\"menu-pop\" role=\"group\" aria-label=\"More actions\">")
26343082 if templ_7745c5c3_Err != nil {
26353083 return templ_7745c5c3_Err
26363084 }
26373085 if p.Box.CanMarkRead {
2638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "<form method=\"post\" action=\"")
3086+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "<form method=\"post\" action=\"")
26393087 if templ_7745c5c3_Err != nil {
26403088 return templ_7745c5c3_Err
26413089 }
2642- var templ_7745c5c3_Var126 templ.SafeURL
2643- templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
3090+ var templ_7745c5c3_Var147 templ.SafeURL
3091+ templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
26443092 if templ_7745c5c3_Err != nil {
2645- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 511, Col: 64}
3093+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 599, Col: 64}
26463094 }
2647- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
3095+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
26483096 if templ_7745c5c3_Err != nil {
26493097 return templ_7745c5c3_Err
26503098 }
2651- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
3099+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "\"><input type=\"hidden\" name=\"head_id\" value=\"")
26523100 if templ_7745c5c3_Err != nil {
26533101 return templ_7745c5c3_Err
26543102 }
2655- var templ_7745c5c3_Var127 string
2656- templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
3103+ var templ_7745c5c3_Var148 string
3104+ templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
26573105 if templ_7745c5c3_Err != nil {
2658- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 512, Col: 59}
3106+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 59}
26593107 }
2660- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var127)
3108+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
26613109 if templ_7745c5c3_Err != nil {
26623110 return templ_7745c5c3_Err
26633111 }
2664- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
3112+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "\"> <button type=\"submit\" title=\"the next visit can show only what changed after this head\" class=\"btn\" aria-keyshortcuts=\"Shift+R\" data-read>Mark read <kbd aria-hidden=\"true\">R</kbd></button></form>")
26653113 if templ_7745c5c3_Err != nil {
26663114 return templ_7745c5c3_Err
26673115 }
26683116 }
2669- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "<form method=\"post\" action=\"")
3117+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<form method=\"post\" action=\"")
26703118 if templ_7745c5c3_Err != nil {
26713119 return templ_7745c5c3_Err
26723120 }
2673- var templ_7745c5c3_Var128 templ.SafeURL
2674- templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
3121+ var templ_7745c5c3_Var149 templ.SafeURL
3122+ templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
26753123 if templ_7745c5c3_Err != nil {
2676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 516, Col: 64}
3124+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 604, Col: 64}
26773125 }
2678- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128))
3126+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149))
26793127 if templ_7745c5c3_Err != nil {
26803128 return templ_7745c5c3_Err
26813129 }
2682- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
3130+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "\"><button type=\"submit\" title=\"close without merging; the branch is kept\" class=\"btn danger\">Close</button></form>")
26833131 if templ_7745c5c3_Err != nil {
26843132 return templ_7745c5c3_Err
26853133 }
26863134 if !p.Box.HeadGone && p.Box.ReadyAction == "" {
2687- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
3135+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "<details class=\"d-or\"><summary class=\"mute\">or from a terminal</summary>")
26883136 if templ_7745c5c3_Err != nil {
26893137 return templ_7745c5c3_Err
26903138 }
@@ -2692,12 +3140,12 @@ func moreMenu(p MRPage) templ.Component {
26923140 if templ_7745c5c3_Err != nil {
26933141 return templ_7745c5c3_Err
26943142 }
2695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, "</details>")
3143+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "</details>")
26963144 if templ_7745c5c3_Err != nil {
26973145 return templ_7745c5c3_Err
26983146 }
26993147 }
2700- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "</div></details>")
3148+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</div></details>")
27013149 if templ_7745c5c3_Err != nil {
27023150 return templ_7745c5c3_Err
27033151 }
@@ -2721,12 +3169,12 @@ func Compare(l Layout, p ComparePage) templ.Component {
27213169 }()
27223170 }
27233171 ctx = templ.InitializeContext(ctx)
2724- templ_7745c5c3_Var129 := templ.GetChildren(ctx)
2725- if templ_7745c5c3_Var129 == nil {
2726- templ_7745c5c3_Var129 = templ.NopComponent
3172+ templ_7745c5c3_Var150 := templ.GetChildren(ctx)
3173+ if templ_7745c5c3_Var150 == nil {
3174+ templ_7745c5c3_Var150 = templ.NopComponent
27273175 }
27283176 ctx = templ.ClearChildren(ctx)
2729- templ_7745c5c3_Var130 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
3177+ templ_7745c5c3_Var151 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
27303178 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
27313179 templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
27323180 if !templ_7745c5c3_IsBuffer {
@@ -2738,263 +3186,263 @@ func Compare(l Layout, p ComparePage) templ.Component {
27383186 }()
27393187 }
27403188 ctx = templ.InitializeContext(ctx)
2741- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "<header class=\"show mr-h\"><h1>")
3189+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "<header class=\"show mr-h\"><h1>")
27423190 if templ_7745c5c3_Err != nil {
27433191 return templ_7745c5c3_Err
27443192 }
2745- var templ_7745c5c3_Var131 string
2746- templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
3193+ var templ_7745c5c3_Var152 string
3194+ templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
27473195 if templ_7745c5c3_Err != nil {
2748- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 17}
3196+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 620, Col: 17}
27493197 }
2750- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
3198+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
27513199 if templ_7745c5c3_Err != nil {
27523200 return templ_7745c5c3_Err
27533201 }
2754- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " → ")
3202+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " → ")
27553203 if templ_7745c5c3_Err != nil {
27563204 return templ_7745c5c3_Err
27573205 }
2758- var templ_7745c5c3_Var132 string
2759- templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3206+ var templ_7745c5c3_Var153 string
3207+ templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
27603208 if templ_7745c5c3_Err != nil {
2761- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 532, Col: 34}
3209+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 620, Col: 34}
27623210 }
2763- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
3211+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
27643212 if templ_7745c5c3_Err != nil {
27653213 return templ_7745c5c3_Err
27663214 }
2767- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
3215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "</h1><dl class=\"kv show-kv\"><dt>branch</dt><dd><a href=\"")
27683216 if templ_7745c5c3_Err != nil {
27693217 return templ_7745c5c3_Err
27703218 }
2771- var templ_7745c5c3_Var133 templ.SafeURL
2772- templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
3219+ var templ_7745c5c3_Var154 templ.SafeURL
3220+ templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
27733221 if templ_7745c5c3_Err != nil {
2774- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 45}
3222+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 623, Col: 45}
27753223 }
2776- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
3224+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
27773225 if templ_7745c5c3_Err != nil {
27783226 return templ_7745c5c3_Err
27793227 }
2780- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "\">")
3228+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "\">")
27813229 if templ_7745c5c3_Err != nil {
27823230 return templ_7745c5c3_Err
27833231 }
2784- var templ_7745c5c3_Var134 string
2785- templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
3232+ var templ_7745c5c3_Var155 string
3233+ templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
27863234 if templ_7745c5c3_Err != nil {
2787- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 535, Col: 58}
3235+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 623, Col: 58}
27883236 }
2789- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
3237+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
27903238 if templ_7745c5c3_Err != nil {
27913239 return templ_7745c5c3_Err
27923240 }
2793- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a></dd><dt>target</dt><dd><a href=\"")
3241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</a></dd><dt>target</dt><dd><a href=\"")
27943242 if templ_7745c5c3_Err != nil {
27953243 return templ_7745c5c3_Err
27963244 }
2797- var templ_7745c5c3_Var135 templ.SafeURL
2798- templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
3245+ var templ_7745c5c3_Var156 templ.SafeURL
3246+ templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
27993247 if templ_7745c5c3_Err != nil {
2800- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 45}
3248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 625, Col: 45}
28013249 }
2802- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
3250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
28033251 if templ_7745c5c3_Err != nil {
28043252 return templ_7745c5c3_Err
28053253 }
2806- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">")
3254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "\">")
28073255 if templ_7745c5c3_Err != nil {
28083256 return templ_7745c5c3_Err
28093257 }
2810- var templ_7745c5c3_Var136 string
2811- templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3258+ var templ_7745c5c3_Var157 string
3259+ templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
28123260 if templ_7745c5c3_Err != nil {
2813- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 537, Col: 58}
3261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 625, Col: 58}
28143262 }
2815- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
3263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157))
28163264 if templ_7745c5c3_Err != nil {
28173265 return templ_7745c5c3_Err
28183266 }
2819- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a></dd><dt>ahead</dt><dd>")
3267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</a></dd><dt>ahead</dt><dd>")
28203268 if templ_7745c5c3_Err != nil {
28213269 return templ_7745c5c3_Err
28223270 }
2823- var templ_7745c5c3_Var137 string
2824- templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
3271+ var templ_7745c5c3_Var158 string
3272+ templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
28253273 if templ_7745c5c3_Err != nil {
2826- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 539, Col: 21}
3274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 627, Col: 21}
28273275 }
2828- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137))
3276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
28293277 if templ_7745c5c3_Err != nil {
28303278 return templ_7745c5c3_Err
28313279 }
2832- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "</dd></dl></header>")
3280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "</dd></dl></header>")
28333281 if templ_7745c5c3_Err != nil {
28343282 return templ_7745c5c3_Err
28353283 }
28363284 if p.Note != "" {
2837- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<p class=\"notice\">")
3285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, "<p class=\"notice\">")
28383286 if templ_7745c5c3_Err != nil {
28393287 return templ_7745c5c3_Err
28403288 }
2841- var templ_7745c5c3_Var138 string
2842- templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
3289+ var templ_7745c5c3_Var159 string
3290+ templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
28433291 if templ_7745c5c3_Err != nil {
2844- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 543, Col: 29}
3292+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 631, Col: 29}
28453293 }
2846- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
3294+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
28473295 if templ_7745c5c3_Err != nil {
28483296 return templ_7745c5c3_Err
28493297 }
2850- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "</p>")
3298+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "</p>")
28513299 if templ_7745c5c3_Err != nil {
28523300 return templ_7745c5c3_Err
28533301 }
28543302 }
2855- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, " ")
3303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, " ")
28563304 if templ_7745c5c3_Err != nil {
28573305 return templ_7745c5c3_Err
28583306 }
28593307 if p.Existing != nil {
2860- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
3308+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "<p class=\"notice\">an open merge request already covers these branches: <a href=\"")
28613309 if templ_7745c5c3_Err != nil {
28623310 return templ_7745c5c3_Err
28633311 }
2864- var templ_7745c5c3_Var139 templ.SafeURL
2865- templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
3312+ var templ_7745c5c3_Var160 templ.SafeURL
3313+ templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
28663314 if templ_7745c5c3_Err != nil {
2867- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 114}
3315+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 634, Col: 114}
28683316 }
2869- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
3317+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
28703318 if templ_7745c5c3_Err != nil {
28713319 return templ_7745c5c3_Err
28723320 }
2873- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "\">")
3321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "\">")
28743322 if templ_7745c5c3_Err != nil {
28753323 return templ_7745c5c3_Err
28763324 }
2877- var templ_7745c5c3_Var140 string
2878- templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
3325+ var templ_7745c5c3_Var161 string
3326+ templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
28793327 if templ_7745c5c3_Err != nil {
2880- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 546, Col: 135}
3328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 634, Col: 135}
28813329 }
2882- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
3330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161))
28833331 if templ_7745c5c3_Err != nil {
28843332 return templ_7745c5c3_Err
28853333 }
2886- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</a></p>")
3334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "</a></p>")
28873335 if templ_7745c5c3_Err != nil {
28883336 return templ_7745c5c3_Err
28893337 }
28903338 }
2891- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, " ")
3339+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, " ")
28923340 if templ_7745c5c3_Err != nil {
28933341 return templ_7745c5c3_Err
28943342 }
28953343 if p.Create != nil {
2896- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<form class=\"cform create\" method=\"post\" action=\"")
3344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 308, "<form class=\"cform create\" method=\"post\" action=\"")
28973345 if templ_7745c5c3_Err != nil {
28983346 return templ_7745c5c3_Err
28993347 }
2900- var templ_7745c5c3_Var141 templ.SafeURL
2901- templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
3348+ var templ_7745c5c3_Var162 templ.SafeURL
3349+ templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
29023350 if templ_7745c5c3_Err != nil {
2903- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 549, Col: 83}
3351+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 637, Col: 83}
29043352 }
2905- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141))
3353+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162))
29063354 if templ_7745c5c3_Err != nil {
29073355 return templ_7745c5c3_Err
29083356 }
2909- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "\"><input type=\"hidden\" name=\"source\" value=\"")
3357+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 309, "\"><input type=\"hidden\" name=\"source\" value=\"")
29103358 if templ_7745c5c3_Err != nil {
29113359 return templ_7745c5c3_Err
29123360 }
2913- var templ_7745c5c3_Var142 string
2914- templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
3361+ var templ_7745c5c3_Var163 string
3362+ templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
29153363 if templ_7745c5c3_Err != nil {
2916- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 550, Col: 62}
3364+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 638, Col: 62}
29173365 }
2918- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var142)
3366+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var163)
29193367 if templ_7745c5c3_Err != nil {
29203368 return templ_7745c5c3_Err
29213369 }
2922- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "\"> <input type=\"hidden\" name=\"target\" value=\"")
3370+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 310, "\"> <input type=\"hidden\" name=\"target\" value=\"")
29233371 if templ_7745c5c3_Err != nil {
29243372 return templ_7745c5c3_Err
29253373 }
2926- var templ_7745c5c3_Var143 string
2927- templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
3374+ var templ_7745c5c3_Var164 string
3375+ templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
29283376 if templ_7745c5c3_Err != nil {
2929- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 551, Col: 62}
3377+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 639, Col: 62}
29303378 }
2931- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var143)
3379+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var164)
29323380 if templ_7745c5c3_Err != nil {
29333381 return templ_7745c5c3_Err
29343382 }
2935- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
3383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 311, "\"> <label for=\"mr-title\">title</label> <input id=\"mr-title\" name=\"title\" type=\"text\" maxlength=\"120\" required value=\"")
29363384 if templ_7745c5c3_Err != nil {
29373385 return templ_7745c5c3_Err
29383386 }
2939- var templ_7745c5c3_Var144 string
2940- templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
3387+ var templ_7745c5c3_Var165 string
3388+ templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
29413389 if templ_7745c5c3_Err != nil {
2942- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 553, Col: 97}
3390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 641, Col: 97}
29433391 }
2944- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var144)
3392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var165)
29453393 if templ_7745c5c3_Err != nil {
29463394 return templ_7745c5c3_Err
29473395 }
2948- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
3396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 312, "\" autocomplete=\"off\"> <label for=\"mr-desc\">description</label> <textarea id=\"mr-desc\" name=\"description\" rows=\"4\" maxlength=\"8000\" placeholder=\"Markdown, optional\"></textarea><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\">Create merge request</button></div></form>")
29493397 if templ_7745c5c3_Err != nil {
29503398 return templ_7745c5c3_Err
29513399 }
29523400 }
2953- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, " ")
3401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 313, " ")
29543402 if templ_7745c5c3_Err != nil {
29553403 return templ_7745c5c3_Err
29563404 }
29573405 if p.HasDiff {
2958- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
3406+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 314, "<div class=\"diff-tools\" role=\"group\" aria-label=\"Diff view\"><span class=\"dim\">")
29593407 if templ_7745c5c3_Err != nil {
29603408 return templ_7745c5c3_Err
29613409 }
2962- var templ_7745c5c3_Var145 string
2963- templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
3410+ var templ_7745c5c3_Var166 string
3411+ templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
29643412 if templ_7745c5c3_Err != nil {
2965- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 57}
3413+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 651, Col: 57}
29663414 }
2967- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145))
3415+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166))
29683416 if templ_7745c5c3_Err != nil {
29693417 return templ_7745c5c3_Err
29703418 }
2971- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, " · <span class=\"plus\">+")
3419+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 315, " · <span class=\"plus\">+")
29723420 if templ_7745c5c3_Err != nil {
29733421 return templ_7745c5c3_Err
29743422 }
2975- var templ_7745c5c3_Var146 string
2976- templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
3423+ var templ_7745c5c3_Var167 string
3424+ templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
29773425 if templ_7745c5c3_Err != nil {
2978- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 97}
3426+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 651, Col: 97}
29793427 }
2980- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146))
3428+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167))
29813429 if templ_7745c5c3_Err != nil {
29823430 return templ_7745c5c3_Err
29833431 }
2984- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "</span> <span class=\"minus\">-")
3432+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 316, "</span> <span class=\"minus\">-")
29853433 if templ_7745c5c3_Err != nil {
29863434 return templ_7745c5c3_Err
29873435 }
2988- var templ_7745c5c3_Var147 string
2989- templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
3436+ var templ_7745c5c3_Var168 string
3437+ templ_7745c5c3_Var168, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
29903438 if templ_7745c5c3_Err != nil {
2991- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 563, Col: 142}
3439+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 651, Col: 142}
29923440 }
2993- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147))
3441+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var168))
29943442 if templ_7745c5c3_Err != nil {
29953443 return templ_7745c5c3_Err
29963444 }
2997- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "</span></span> <span class=\"grow\"></span>")
3445+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 317, "</span></span> <span class=\"grow\"></span>")
29983446 if templ_7745c5c3_Err != nil {
29993447 return templ_7745c5c3_Err
30003448 }
@@ -3010,64 +3458,64 @@ func Compare(l Layout, p ComparePage) templ.Component {
30103458 if templ_7745c5c3_Err != nil {
30113459 return templ_7745c5c3_Err
30123460 }
3013- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, "</div>")
3461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 318, "</div>")
30143462 if templ_7745c5c3_Err != nil {
30153463 return templ_7745c5c3_Err
30163464 }
30173465 if p.Invisible {
3018- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
3466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 319, "<p class=\"notice\">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class=\"inv\">&lt;U+XXXX&gt;</span> markers, never applied.</p>")
30193467 if templ_7745c5c3_Err != nil {
30203468 return templ_7745c5c3_Err
30213469 }
30223470 }
3023- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, " <div id=\"diffs\"")
3471+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 320, " <div id=\"diffs\"")
30243472 if templ_7745c5c3_Err != nil {
30253473 return templ_7745c5c3_Err
30263474 }
30273475 if p.Both {
3028- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, " data-diff=\"")
3476+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 321, " data-diff=\"")
30293477 if templ_7745c5c3_Err != nil {
30303478 return templ_7745c5c3_Err
30313479 }
3032- var templ_7745c5c3_Var148 string
3033- templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3480+ var templ_7745c5c3_Var169 string
3481+ templ_7745c5c3_Var169, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
30343482 if templ_7745c5c3_Err != nil {
3035- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 578, Col: 23}
3483+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 666, Col: 23}
30363484 }
3037- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var148)
3485+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var169)
30383486 if templ_7745c5c3_Err != nil {
30393487 return templ_7745c5c3_Err
30403488 }
3041- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "\"")
3489+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 322, "\"")
30423490 if templ_7745c5c3_Err != nil {
30433491 return templ_7745c5c3_Err
30443492 }
30453493 } else {
3046- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, " data-view=\"")
3494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 323, " data-view=\"")
30473495 if templ_7745c5c3_Err != nil {
30483496 return templ_7745c5c3_Err
30493497 }
3050- var templ_7745c5c3_Var149 string
3051- templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
3498+ var templ_7745c5c3_Var170 string
3499+ templ_7745c5c3_Var170, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
30523500 if templ_7745c5c3_Err != nil {
3053- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 580, Col: 23}
3501+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 668, Col: 23}
30543502 }
3055- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var149)
3503+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var170)
30563504 if templ_7745c5c3_Err != nil {
30573505 return templ_7745c5c3_Err
30583506 }
3059- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, "\"")
3507+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 324, "\"")
30603508 if templ_7745c5c3_Err != nil {
30613509 return templ_7745c5c3_Err
30623510 }
30633511 }
30643512 if p.Both && !p.Pinned {
3065- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
3513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 325, " hx-live=\"this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff\"")
30663514 if templ_7745c5c3_Err != nil {
30673515 return templ_7745c5c3_Err
30683516 }
30693517 }
3070- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, ">")
3518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 326, ">")
30713519 if templ_7745c5c3_Err != nil {
30723520 return templ_7745c5c3_Err
30733521 }
@@ -3077,14 +3525,14 @@ func Compare(l Layout, p ComparePage) templ.Component {
30773525 return templ_7745c5c3_Err
30783526 }
30793527 }
3080- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "</div>")
3528+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 327, "</div>")
30813529 if templ_7745c5c3_Err != nil {
30823530 return templ_7745c5c3_Err
30833531 }
30843532 }
30853533 return nil
30863534 })
3087- templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var130), templ_7745c5c3_Buffer)
3535+ templ_7745c5c3_Err = Shell(l, compareRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var151), templ_7745c5c3_Buffer)
30883536 if templ_7745c5c3_Err != nil {
30893537 return templ_7745c5c3_Err
30903538 }
@@ -3108,173 +3556,173 @@ func compareRail(p ComparePage) templ.Component {
31083556 }()
31093557 }
31103558 ctx = templ.InitializeContext(ctx)
3111- templ_7745c5c3_Var150 := templ.GetChildren(ctx)
3112- if templ_7745c5c3_Var150 == nil {
3113- templ_7745c5c3_Var150 = templ.NopComponent
3559+ templ_7745c5c3_Var171 := templ.GetChildren(ctx)
3560+ if templ_7745c5c3_Var171 == nil {
3561+ templ_7745c5c3_Var171 = templ.NopComponent
31143562 }
31153563 ctx = templ.ClearChildren(ctx)
3116- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
3564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 328, "<section aria-labelledby=\"r-ahead\"><h2 id=\"r-ahead\">Commits <span class=\"count\">")
31173565 if templ_7745c5c3_Err != nil {
31183566 return templ_7745c5c3_Err
31193567 }
3120- var templ_7745c5c3_Var151 string
3121- templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
3568+ var templ_7745c5c3_Var172 string
3569+ templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
31223570 if templ_7745c5c3_Err != nil {
3123- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 596, Col: 67}
3571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 684, Col: 67}
31243572 }
3125- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151))
3573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172))
31263574 if templ_7745c5c3_Err != nil {
31273575 return templ_7745c5c3_Err
31283576 }
3129- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "</span></h2><ul class=\"pcommits\">")
3577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 329, "</span></h2><ul class=\"pcommits\">")
31303578 if templ_7745c5c3_Err != nil {
31313579 return templ_7745c5c3_Err
31323580 }
31333581 for _, c := range p.Ahead {
3134- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "<li><a href=\"")
3582+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 330, "<li><a href=\"")
31353583 if templ_7745c5c3_Err != nil {
31363584 return templ_7745c5c3_Err
31373585 }
3138- var templ_7745c5c3_Var152 templ.SafeURL
3139- templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
3586+ var templ_7745c5c3_Var173 templ.SafeURL
3587+ templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
31403588 if templ_7745c5c3_Err != nil {
3141- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 37}
3589+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 688, Col: 37}
31423590 }
3143- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152))
3591+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173))
31443592 if templ_7745c5c3_Err != nil {
31453593 return templ_7745c5c3_Err
31463594 }
3147- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "\">")
3595+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 331, "\">")
31483596 if templ_7745c5c3_Err != nil {
31493597 return templ_7745c5c3_Err
31503598 }
3151- var templ_7745c5c3_Var153 string
3152- templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
3599+ var templ_7745c5c3_Var174 string
3600+ templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
31533601 if templ_7745c5c3_Err != nil {
3154- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 600, Col: 49}
3602+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 688, Col: 49}
31553603 }
3156- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153))
3604+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174))
31573605 if templ_7745c5c3_Err != nil {
31583606 return templ_7745c5c3_Err
31593607 }
3160- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "</a> <span class=\"s\">")
3608+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 332, "</a> <span class=\"s\">")
31613609 if templ_7745c5c3_Err != nil {
31623610 return templ_7745c5c3_Err
31633611 }
3164- var templ_7745c5c3_Var154 string
3165- templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
3612+ var templ_7745c5c3_Var175 string
3613+ templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
31663614 if templ_7745c5c3_Err != nil {
3167- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 601, Col: 32}
3615+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 689, Col: 32}
31683616 }
3169- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154))
3617+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175))
31703618 if templ_7745c5c3_Err != nil {
31713619 return templ_7745c5c3_Err
31723620 }
3173- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 293, "</span> <span class=\"mute\">")
3621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 333, "</span> <span class=\"mute\">")
31743622 if templ_7745c5c3_Err != nil {
31753623 return templ_7745c5c3_Err
31763624 }
3177- var templ_7745c5c3_Var155 string
3178- templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
3625+ var templ_7745c5c3_Var176 string
3626+ templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
31793627 if templ_7745c5c3_Err != nil {
3180- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 603, Col: 16}
3628+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 691, Col: 16}
31813629 }
3182- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155))
3630+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176))
31833631 if templ_7745c5c3_Err != nil {
31843632 return templ_7745c5c3_Err
31853633 }
3186- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 294, " ")
3634+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 334, " ")
31873635 if templ_7745c5c3_Err != nil {
31883636 return templ_7745c5c3_Err
31893637 }
31903638 if c.WhenAt != "" {
3191- var templ_7745c5c3_Var156 string
3192- templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
3639+ var templ_7745c5c3_Var177 string
3640+ templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
31933641 if templ_7745c5c3_Err != nil {
3194- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 15}
3642+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 693, Col: 15}
31953643 }
3196- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156))
3644+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177))
31973645 if templ_7745c5c3_Err != nil {
31983646 return templ_7745c5c3_Err
31993647 }
3200- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 295, "<time datetime=\"")
3648+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 335, "<time datetime=\"")
32013649 if templ_7745c5c3_Err != nil {
32023650 return templ_7745c5c3_Err
32033651 }
3204- var templ_7745c5c3_Var157 string
3205- templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
3652+ var templ_7745c5c3_Var178 string
3653+ templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
32063654 if templ_7745c5c3_Err != nil {
3207- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 42}
3655+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 693, Col: 42}
32083656 }
3209- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var157)
3657+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var178)
32103658 if templ_7745c5c3_Err != nil {
32113659 return templ_7745c5c3_Err
32123660 }
3213- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 296, "\">")
3661+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 336, "\">")
32143662 if templ_7745c5c3_Err != nil {
32153663 return templ_7745c5c3_Err
32163664 }
3217- var templ_7745c5c3_Var158 string
3218- templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
3665+ var templ_7745c5c3_Var179 string
3666+ templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
32193667 if templ_7745c5c3_Err != nil {
3220- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 605, Col: 53}
3668+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 693, Col: 53}
32213669 }
3222- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158))
3670+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179))
32233671 if templ_7745c5c3_Err != nil {
32243672 return templ_7745c5c3_Err
32253673 }
3226- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 297, "</time>")
3674+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 337, "</time>")
32273675 if templ_7745c5c3_Err != nil {
32283676 return templ_7745c5c3_Err
32293677 }
32303678 }
3231- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 298, "</span></li>")
3679+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 338, "</span></li>")
32323680 if templ_7745c5c3_Err != nil {
32333681 return templ_7745c5c3_Err
32343682 }
32353683 }
3236- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 299, "</ul>")
3684+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 339, "</ul>")
32373685 if templ_7745c5c3_Err != nil {
32383686 return templ_7745c5c3_Err
32393687 }
32403688 if len(p.Ahead) == 0 {
3241- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 300, "<p class=\"dim\">no commit ahead of ")
3689+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 340, "<p class=\"dim\">no commit ahead of ")
32423690 if templ_7745c5c3_Err != nil {
32433691 return templ_7745c5c3_Err
32443692 }
3245- var templ_7745c5c3_Var159 string
3246- templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
3693+ var templ_7745c5c3_Var180 string
3694+ templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
32473695 if templ_7745c5c3_Err != nil {
3248- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 612, Col: 47}
3696+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 700, Col: 47}
32493697 }
3250- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159))
3698+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180))
32513699 if templ_7745c5c3_Err != nil {
32523700 return templ_7745c5c3_Err
32533701 }
3254- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 301, ".</p>")
3702+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 341, ".</p>")
32553703 if templ_7745c5c3_Err != nil {
32563704 return templ_7745c5c3_Err
32573705 }
32583706 }
3259- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 302, "</section>")
3707+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 342, "</section>")
32603708 if templ_7745c5c3_Err != nil {
32613709 return templ_7745c5c3_Err
32623710 }
32633711 if p.HasDiff {
3264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 303, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
3712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 343, "<section aria-labelledby=\"r-files\"><h2 id=\"r-files\">Files <span class=\"count\">")
32653713 if templ_7745c5c3_Err != nil {
32663714 return templ_7745c5c3_Err
32673715 }
3268- var templ_7745c5c3_Var160 string
3269- templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
3716+ var templ_7745c5c3_Var181 string
3717+ templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
32703718 if templ_7745c5c3_Err != nil {
3271- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 617, Col: 66}
3719+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 705, Col: 66}
32723720 }
3273- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160))
3721+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181))
32743722 if templ_7745c5c3_Err != nil {
32753723 return templ_7745c5c3_Err
32763724 }
3277- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 304, "</span></h2>")
3725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 344, "</span></h2>")
32783726 if templ_7745c5c3_Err != nil {
32793727 return templ_7745c5c3_Err
32803728 }
@@ -3282,12 +3730,12 @@ func compareRail(p ComparePage) templ.Component {
32823730 if templ_7745c5c3_Err != nil {
32833731 return templ_7745c5c3_Err
32843732 }
3285- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 305, "</section>")
3733+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 345, "</section>")
32863734 if templ_7745c5c3_Err != nil {
32873735 return templ_7745c5c3_Err
32883736 }
32893737 }
3290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 306, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
3738+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 346, "<section aria-labelledby=\"r-cli\"><h2 id=\"r-cli\">From a terminal</h2>")
32913739 if templ_7745c5c3_Err != nil {
32923740 return templ_7745c5c3_Err
32933741 }
@@ -3295,7 +3743,7 @@ func compareRail(p ComparePage) templ.Component {
32953743 if templ_7745c5c3_Err != nil {
32963744 return templ_7745c5c3_Err
32973745 }
3298- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 307, "</section>")
3746+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 347, "</section>")
32993747 if templ_7745c5c3_Err != nil {
33003748 return templ_7745c5c3_Err
33013749 }
A internal/adapters/web/mr_test.go +130 -0
@@ -0,0 +1,130 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
19+ var v ports.RepoView
20+ v.Settings.AllowedMergeModes = modes
21+ return v
22+}
23+
24+func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
25+
26+func TestMergeabilityView(t *testing.T) {
27+ t.Parallel()
28+ for _, tt := range []struct {
29+ name string
30+ m domain.Mergeability
31+ label, detail string
32+ ffOK, mergeOK bool
33+ note string
34+ paths []string
35+ }{
36+ {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
37+ {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
38+ {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
39+ {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
40+ {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
41+ {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
42+ {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
43+ {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
44+ {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
45+ } {
46+ t.Run(tt.name, func(t *testing.T) {
47+ t.Parallel()
48+ v := mergeabilityView(tt.m, "main")
49+ assert.Equal(t, tt.label, v.Label)
50+ assert.Equal(t, tt.detail, v.Detail)
51+ assert.Equal(t, tt.ffOK, v.FFOk)
52+ assert.Equal(t, tt.mergeOK, v.MergeOk)
53+ assert.Equal(t, tt.note, v.PathsNote)
54+ assert.Equal(t, tt.paths, v.Paths)
55+ assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
56+ assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
57+ })
58+ }
59+}
60+
61+func TestMergeBoxModes(t *testing.T) {
62+ t.Parallel()
63+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
64+ s := &Server{}
65+
66+ behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
67+ require.Len(t, behind.Modes, 3)
68+ assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
69+ assert.NotEmpty(t, behind.Modes[0].Reason)
70+ assert.False(t, behind.Modes[1].Disabled)
71+ assert.False(t, behind.Modes[2].Disabled)
72+ assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
73+ assert.True(t, behind.CanMerge())
74+ assert.True(t, behind.Signed)
75+
76+ conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
77+ assert.False(t, conflict.CanMerge())
78+ assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
79+ assert.Equal(t, 0, conflict.Checked())
80+ assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
81+ assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
82+}
83+
84+func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
85+ t.Parallel()
86+ d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
87+ b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
88+ assert.False(t, b.Signed, "no commit is created, nothing to explain")
89+ assert.Len(t, b.Modes, 1)
90+}
91+
92+func TestMergeBoxNamesTheSigningKey(t *testing.T) {
93+ t.Parallel()
94+ key := mocks.NewSigningKeyService(t)
95+ key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
96+ d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
97+ b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main"))
98+ assert.Equal(t, "SHA256:abc", b.Fingerprint)
99+}
100+
101+func TestMergeErrorTravelsInTheContext(t *testing.T) {
102+ t.Parallel()
103+ assert.Empty(t, mergeErrorOf(context.Background()))
104+ assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
105+}
106+
107+func TestMergeabilityLiveEvents(t *testing.T) {
108+ t.Parallel()
109+ assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
110+ svc := mocks.NewMergeRequestService(t)
111+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
112+ Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
113+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
114+ ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
115+
116+ fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
117+ require.NoError(t, err)
118+ require.Len(t, fs, 1)
119+ assert.Equal(t, "mr-mergeability", fs[0].event)
120+ assert.Contains(t, fs[0].html, "Behind main by 3 commits")
121+
122+ // Another request, or a page that is not one: nothing.
123+ fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
124+ require.NoError(t, err)
125+ assert.Empty(t, fs)
126+ // The box is the owner's.
127+ fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
128+ require.NoError(t, err)
129+ assert.Empty(t, fs)
130+}
M internal/adapters/web/static/css/60-mr.css +22 -1
@@ -50,6 +50,12 @@
5050 & .kv { margin: 0; }
5151 }
5252 .mmenu {
53+ & .menu-pop { box-sizing: border-box; min-width: 0; overflow-wrap: anywhere; }
54+ & .menu-pop > *, & form, & .d-modes, & .d-btns { min-width: 0; max-width: 100%; box-sizing: border-box; }
55+ & .d-acts { display: grid; gap: var(--s3); }
56+ & .d-modes label, & .d-check { min-width: 0; }
57+ & code { overflow-wrap: anywhere; }
58+ & .d-btns .btn { min-width: 0; max-width: 100%; white-space: normal; }
5359 & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; }
5460 & .d-modes label, & .d-check {
5561 display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2));
@@ -63,7 +69,22 @@
6369 & .d-btns { display: flex; margin-top: var(--s1); }
6470 & .d-btns .btn { flex: 1; justify-content: center; }
6571 & .d-draft { color: var(--ink-dim); }
66- }
72+ & .d-note { margin: 0; color: var(--ink-dim); }
73+ & .d-err { margin: 0; padding: var(--s2) var(--s3); color: var(--ink-hi); border: 1px solid var(--red); border-left-width: 3px; border-radius: var(--r-s); overflow-wrap: anywhere; }
74+ /* the badge is a live slot, but not a banner */
75+ & #mr-mergeability:not(:empty) { padding: 0; font-weight: normal; background: none; border: 0; display: grid; gap: var(--s1); }
76+ & .mg-paths { margin: 0; padding-left: 2ch; max-height: 8em; overflow: auto; }
77+ & .mg-paths code { overflow-wrap: anywhere; }
78+ & .d-modes label:has(:disabled) { color: var(--ink-mute); cursor: not-allowed; }
79+ & .d-modes label:has(:disabled):hover { background: none; }
80+ & .btn:disabled { opacity: .5; cursor: not-allowed; }
81+ }
82+ .badge[data-state] { display: inline-block; padding: 0 var(--s2); border: 1px solid var(--border-strong); border-radius: var(--r-s); font-weight: 600; color: var(--ink-hi); }
83+ .badge[data-state="up_to_date"] { border-color: var(--teal); color: var(--teal); }
84+ .badge[data-state="behind"] { border-color: var(--teal-2); }
85+ .badge[data-state="conflict"] { border-color: var(--red); color: var(--red); }
86+ .badge[data-state="checking"] { color: var(--ink-dim); border-style: dashed; }
87+ .note[role="status"] { color: var(--ink-dim); }
6788 .omenu {
6889 & > summary { font-size: 18px; line-height: 1; }
6990 & .menu-pop { width: 280px; }
M internal/adapters/web/static/js/mr.js +10 -1
@@ -219,7 +219,12 @@ window.ermiteMR = (() => {
219219 const box = $('input[name="delete_branch"]', m);
220220 if (box) box.checked = !box.checked;
221221 } else {
222- radios[act.mode].checked = true;
222+ const r = radios[act.mode];
223+ // A change event lets the live bindings enable the Merge button again.
224+ if (!r.disabled && !r.checked) {
225+ r.checked = true;
226+ r.dispatchEvent(new Event('change', { bubbles: true }));
227+ }
223228 }
224229 $('[data-default]', m)?.focus();
225230 return true;
@@ -291,6 +296,10 @@ window.ermiteMR = (() => {
291296 let folded = false;
292297 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
293298 if (folded) foldFiles(true);
299+ // The page answers a refused action from the address of the action: show
300+ // the address of the request, so that a reload does not post again.
301+ const canonical = $('#diffs[data-canonical]')?.dataset.canonical;
302+ if (canonical && location.pathname !== canonical) history.replaceState(history.state, '', canonical + location.hash);
294303 watch();
295304 // A link to a file or a comment that is not loaded yet.
296305 const hash = () => {
M internal/adapters/web/web.go +4 -0
@@ -76,6 +76,10 @@ type Config struct {
7676 // leads to it. Without it there is neither. Leave it unset rather than
7777 // assigning a typed nil pointer.
7878 Inbox ports.InboxService
79+ // SigningKey tells the key that signs the squash and merge commits, shown
80+ // in the merge box. Without it the box does not name the key. Leave it
81+ // unset rather than assigning a typed nil pointer.
82+ SigningKey ports.SigningKeyService
7983 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
8084 // commits and merge requests. Without it there are no such pages. Leave it
8185 // unset rather than assigning a typed nil pointer.
M internal/app/api_helpers_test.go +14 -1
@@ -167,8 +167,21 @@ func (h *harness) watch(c *clients, resume string, topics ...string) *stream {
167167 return s
168168 }
169169
170-// next returns the next event or fails after d.
170+// next returns the next event or fails after d. Mergeability events, which
171+// arrive asynchronously after a push or an opening, are skipped: use
172+// nextAny to see them.
171173 func (s *stream) next(t *testing.T, d time.Duration) *pb.Event {
174+ t.Helper()
175+ for {
176+ ev := s.nextAny(t, d)
177+ if ev.GetMergeRequestMergeability() == nil {
178+ return ev
179+ }
180+ }
181+}
182+
183+// nextAny returns the next event, whatever its kind, or fails after d.
184+func (s *stream) nextAny(t *testing.T, d time.Duration) *pb.Event {
172185 t.Helper()
173186 select {
174187 case ev, ok := <-s.events:
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/ci_schedule_trust_test.go +6 -2
@@ -3,6 +3,7 @@
33 package app
44
55 import (
6+ "errors"
67 "strings"
78 "testing"
89 "time"
@@ -230,8 +231,11 @@ func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
230231 }))
231232 require.NoError(t, err)
232233
233- _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
234- assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")
234+ // The deletion and the removal of the record run after the merge returned.
235+ c.waitFor("the deleted source branch has no record", func() bool {
236+ _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
237+ return errors.Is(err, domain.ErrNotFound)
238+ })
235239 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236240 require.NoError(t, err)
237241 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
M internal/app/cli_e2e_test.go +14 -2
@@ -123,8 +123,20 @@ func (c *cliHarness) startWatch(t *testing.T, secret string, args ...string) *wa
123123 return w
124124 }
125125
126-// next decodes the next event line or fails after d.
126+// next returns the next event line or fails after d. Mergeability events,
127+// which arrive asynchronously after a push or an opening, are skipped.
127128 func (w *watchProc) next(t *testing.T, d time.Duration) (raw string) {
129+ t.Helper()
130+ for {
131+ l := w.nextAny(t, d)
132+ if !strings.Contains(l, `"type":"`+eventMRMergeability+`"`) {
133+ return l
134+ }
135+ }
136+}
137+
138+// nextAny returns the next event line, whatever its kind, or fails after d.
139+func (w *watchProc) nextAny(t *testing.T, d time.Duration) (raw string) {
128140 t.Helper()
129141 select {
130142 case l, ok := <-w.lines:
@@ -338,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338350 ver := decode[versionOut](t, res.stdout)
339351 assert.Equal(t, c.url, ver.Server.URL)
340352 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "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.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343355 stdout, stderr, code = c.bin(t, secret, "version")
344356 require.Equal(t, 0, code, stderr)
M internal/app/cli_mr.go +150 -24
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "net/url"
11+ "slices"
1112 "strconv"
1213 "strings"
1314
@@ -31,6 +32,7 @@ const (
3132
3233 modeFastForward = "fast-forward"
3334 modeSquash = "squash"
35+ modeMerge = "merge"
3436 )
3537
3638 func stateName(s ermite.MergeRequestState) string {
@@ -65,6 +67,8 @@ func modeName(m ermite.MergeMode) string {
6567 return modeFastForward
6668 case ermite.MergeMode_MERGE_MODE_SQUASH:
6769 return modeSquash
70+ case ermite.MergeMode_MERGE_MODE_MERGE_COMMIT:
71+ return modeMerge
6872 }
6973 return ""
7074 }
@@ -75,8 +79,10 @@ func parseMode(s string) (ermite.MergeMode, error) {
7579 return ermite.MergeMode_MERGE_MODE_FAST_FORWARD, nil
7680 case modeSquash:
7781 return ermite.MergeMode_MERGE_MODE_SQUASH, nil
82+ case modeMerge:
83+ return ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, nil
7884 }
79- return 0, fmt.Errorf("invalid merge mode %q: use %s or %s", s, modeFastForward, modeSquash)
85+ return 0, fmt.Errorf("invalid merge mode %q: use %s, %s or %s", s, modeFastForward, modeSquash, modeMerge)
8086 }
8187
8288 func sideName(s ermite.DiffSide) string {
@@ -117,6 +123,71 @@ type jsonMergeRequest struct {
117123 MergeMode string `json:"merge_mode"`
118124 }
119125
126+// jsonMergeability is the stable JSON shape of the mergeability of an open
127+// merge request.
128+type jsonMergeability struct {
129+ // State is "checking", "up_to_date", "behind" or "conflict".
130+ State string `json:"state"`
131+ // Behind counts the commits of the target the head lacks (behind only);
132+ // BehindCapped says it is a lower bound.
133+ Behind int32 `json:"behind"`
134+ BehindCapped bool `json:"behind_capped"`
135+ ConflictPaths []string `json:"conflict_paths"`
136+ // ConflictPathsTruncated says that more paths conflict than listed.
137+ ConflictPathsTruncated bool `json:"conflict_paths_truncated"`
138+ TargetID string `json:"target_id"`
139+ HeadID string `json:"head_id"`
140+}
141+
142+func mergeabilityName(s ermite.MergeabilityState) string {
143+ switch s {
144+ case ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING:
145+ return "checking"
146+ case ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE:
147+ return "up_to_date"
148+ case ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND:
149+ return "behind"
150+ case ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT:
151+ return "conflict"
152+ }
153+ return ""
154+}
155+
156+// toJSONMergeability maps a mergeability; nil when the server sent none.
157+func toJSONMergeability(m *ermite.Mergeability) *jsonMergeability {
158+ if m == nil {
159+ return nil
160+ }
161+ return &jsonMergeability{
162+ State: mergeabilityName(m.GetState()), Behind: m.GetBehind(), BehindCapped: m.GetBehindCapped(),
163+ ConflictPaths: append([]string{}, m.GetConflictPaths()...), ConflictPathsTruncated: m.GetConflictPathsTruncated(),
164+ TargetID: m.GetTargetId(), HeadID: m.GetHeadId(),
165+ }
166+}
167+
168+// mergeabilityText is the human form of a mergeability: "up to date",
169+// "behind by 2", "conflict (a, b, ...)", "checking".
170+func mergeabilityText(m *jsonMergeability) string {
171+ switch m.State {
172+ case "up_to_date":
173+ return "up to date with the target, can be fast-forwarded"
174+ case "behind":
175+ return fmt.Sprintf("behind by %d%s, merges without conflicts (squash or merge commit; fast-forward needs a rebase)", m.Behind, plus(m.BehindCapped))
176+ case "conflict":
177+ if len(m.ConflictPaths) == 0 {
178+ return "conflict (no common history with the target), rebase needed"
179+ }
180+ more := ""
181+ if m.ConflictPathsTruncated {
182+ more = ", ..."
183+ }
184+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + "), rebase needed"
185+ case "checking":
186+ return "checking"
187+ }
188+ return m.State
189+}
190+
120191 // jsonMerged is the output of `mr merge --json`.
121192 type jsonMerged struct {
122193 jsonMergeRequest
@@ -173,9 +244,15 @@ const jsonMRHelp = `
173244 JSON fields of a merge request (--json): repo, url (full web URL), number, title, description, source_branch,
174245 target_branch, state ("open", "merged" or "closed"), author (token label), created_at and
175246 updated_at (RFC 3339 UTC), head_id (the commit the source branch points to; empty when the
176-branch is gone), merged_commit_id and merge_mode ("fast-forward" or "squash"; both empty
247+branch is gone), merged_commit_id and merge_mode ("fast-forward", "squash" or "merge"; both empty
177248 unless merged).`
178249
250+const jsonMergeabilityHelp = `
251+mr show --json adds mergeability, null unless the request is open: state ("checking", "up_to_date",
252+"behind" or "conflict"), behind and behind_capped (commits of the target the head lacks; behind
253+only), conflict_paths and conflict_paths_truncated (conflict only), target_id and head_id (the
254+commits the result is for).`
255+
179256 const jsonCommentHelp = `
180257 JSON fields of a comment: id, author, path, line and side ("old" or "new"; empty path, line 0
181258 and empty side for a general comment), commit_id (the head when it was written), text,
@@ -412,6 +489,8 @@ type jsonCommit struct {
412489 // jsonMRDetail is the stable JSON shape of `mr show --json`.
413490 type jsonMRDetail struct {
414491 jsonMergeRequest
492+ // Mergeability is null unless the request is open and the server computes it.
493+ Mergeability *jsonMergeability `json:"mergeability"`
415494 Commits []jsonCommit `json:"commits"`
416495 CommitsTruncated bool `json:"commits_truncated"`
417496 DiffBaseID string `json:"diff_base_id"`
@@ -444,6 +523,7 @@ func lineKindName(k ermite.LineKind) string {
444523 func toJSONDetail(d *ermite.GetMergeRequestResponse, withDiff bool, baseURL string) jsonMRDetail {
445524 out := jsonMRDetail{
446525 jsonMergeRequest: toJSONMergeRequest(d.GetMergeRequest(), baseURL),
526+ Mergeability: toJSONMergeability(d.GetMergeRequest().GetMergeability()),
447527 Commits: []jsonCommit{}, CommitsTruncated: d.GetCommitsTruncated(),
448528 DiffBaseID: d.GetDiffBaseId(),
449529 Stats: jsonStats{
@@ -496,7 +576,9 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
496576 "--since-last-read shows the diff since the head recorded by --mark-read (or by the web) instead of\n" +
497577 "the whole change; after a force-push it still works when the recorded commit exists, else the\n" +
498578 "whole change is shown and the output says so. --mark-read records the head shown as read; it needs\n" +
499- "the mr:write scope." + jsonMRHelp + jsonCommentHelp + "\n" +
579+ "the mr:write scope. The mergeability line says whether the branch merges with its target now: up to\n" +
580+ "date (can be fast-forwarded), behind by N commits (merges without conflicts, squash or merge commit),\n" +
581+ "or in conflict with the paths that conflict (rebase needed)." + jsonMRHelp + jsonMergeabilityHelp + jsonCommentHelp + "\n" +
500582 "--json adds to the merge request fields: commits (id, author, summary), commits_truncated,\n" +
501583 "diff_base_id (the commit the diff starts from), stats (files_changed, additions, deletions),\n" +
502584 "diff_truncated, files (old_path, new_path, status, additions, deletions, binary, truncated and,\n" +
@@ -569,6 +651,9 @@ func printMRDetail(w io.Writer, d *ermite.GetMergeRequestResponse, withDiff, sin
569651 if m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_MERGED {
570652 pairs = append(pairs, [2]string{"merged", orDash(m.GetMergedCommitId()) + " (" + modeName(m.GetMergeMode()) + ")"})
571653 }
654+ if mg := toJSONMergeability(m.GetMergeability()); mg != nil && m.GetState() == ermite.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
655+ pairs = append(pairs, [2]string{"mergeability", mergeabilityText(mg)})
656+ }
572657 if len(d.GetVerdicts()) > 0 {
573658 var verdicts []string
574659 for _, v := range d.GetVerdicts() {
@@ -957,36 +1042,52 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
9571042 }
9581043
9591044 func newMRMergeCmd(env *cliEnv) *cobra.Command {
960- var squash, deleteBranch bool
1045+ var mode string
1046+ var deleteBranch bool
9611047 var head string
9621048 cmd := &cobra.Command{
9631049 Use: "merge <repo> <number>",
9641050 Short: "Merge a merge request",
965- Long: "Merge an open merge request. The default is a fast-forward: the target branch moves to the head\n" +
966- "and no commit is created. --squash makes one new commit with the tree of the head and the target\n" +
967- "as only parent, signed by the server's key (see `ermite signing-key show`). The target must be an\n" +
968- "ancestor of the head, else rebase the branch and push it again; the repository settings may allow\n" +
969- "only one of the modes.\n\n" +
1051+ Long: "Merge an open merge request with --mode (default fast-forward; the repository settings may allow\n" +
1052+ "only some modes, see `ermite repo edit --merge-modes`):\n" +
1053+ " fast-forward the target branch moves to the head and no commit is created. The target must be\n" +
1054+ " an ancestor of the head, else rebase the branch and push it again, or use another mode.\n" +
1055+ " squash one new commit with the tree of the merge of the head and the target, the target tip\n" +
1056+ " as only parent. The branch must merge without conflicts.\n" +
1057+ " merge a merge commit with two parents, the target tip and the head. The branch must merge\n" +
1058+ " without conflicts.\n" +
1059+ "A squash and a merge commit are signed with the server's own key, generated automatically when the\n" +
1060+ "server first starts; `ermite signing-key show` prints it. The server never rebases. `ermite mr show`\n" +
1061+ "prints the mergeability (up to date, behind, conflict); the server checks again when merging and\n" +
1062+ "refuses a merge that is not possible, saying why.\n\n" +
9701063 "The server merges only if the source branch still points to the head you reviewed, so --head is\n" +
9711064 "required: give the full id of the head that `ermite mr show <repo> <number>` printed when you\n" +
9721065 "reviewed the change. If the branch moved since, the merge is refused: review again. Needs the\n" +
9731066 "mr:merge scope.\n\n" +
974- "--delete-branch deletes the source branch after the merge. The merge is refused when the branch\n" +
975- "is the default branch or used by another open merge request. If the deletion fails after the\n" +
976- "merge, the merge stands, the branch is kept and a warning goes to standard error." + jsonMRHelp +
977- "\n--json adds source_branch_deleted (true when the branch was deleted).",
1067+ "The merge answers as soon as the target branch has moved. --delete-branch asks the server to delete\n" +
1068+ "the source branch right after, in the background. The merge is refused when the branch is the\n" +
1069+ "default branch or used by another open merge request. If the deletion later fails (the branch\n" +
1070+ "moved, say), the merge stands and the branch is kept." + jsonMRHelp +
1071+ "\n--json adds source_branch_deleted (true when the deletion was requested: the server deletes the branch right after the merge).",
9781072 Args: cobra.ExactArgs(2),
9791073 ValidArgsFunction: completeMRRepoThenNumber(env),
9801074 }
981- cmd.Flags().BoolVar(&squash, "squash", false, "squash the branch into one signed commit instead of a fast-forward")
1075+ cmd.Flags().StringVar(&mode, "mode", modeFastForward, "merge mode: fast-forward, squash or merge (a merge commit)")
9821076 cmd.Flags().StringVar(&head, "head", "", "full id of the head you reviewed, from `ermite mr show` (required)")
9831077 cmd.Flags().BoolVar(&deleteBranch, "delete-branch", false, "delete the source branch after the merge")
1078+ _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1079+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
1080+ })
9841081 asJSON := addJSONFlag(cmd)
9851082 cmd.RunE = func(cmd *cobra.Command, args []string) error {
9861083 n, err := parseNumber(args[1])
9871084 if err != nil {
9881085 return err
9891086 }
1087+ pbMode, err := parseMode(mode)
1088+ if err != nil {
1089+ return err
1090+ }
9901091 head = strings.TrimSpace(head)
9911092 if head == "" {
9921093 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
@@ -997,35 +1098,60 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
9971098 return err
9981099 }
9991100 ctx := env.ctx(cmd)
1000- mode := ermite.MergeMode_MERGE_MODE_FAST_FORWARD
1001- if squash {
1002- mode = ermite.MergeMode_MERGE_MODE_SQUASH
1101+ if pbMode == ermite.MergeMode_MERGE_MODE_MERGE_COMMIT {
1102+ if err := requireFeature(ctx, c, client.FeatureMergeability, "has no merge commit mode (--mode merge)"); err != nil {
1103+ return err
1104+ }
10031105 }
10041106 res, err := c.MergeRequests().MergeMergeRequest(ctx, connect.NewRequest(&ermite.MergeMergeRequestRequest{
1005- Repo: args[0], Number: n, Mode: mode, HeadId: head, DeleteSourceBranch: deleteBranch,
1107+ Repo: args[0], Number: n, Mode: pbMode, HeadId: head, DeleteSourceBranch: deleteBranch,
10061108 }))
10071109 if err != nil {
10081110 return c.Describe(err)
10091111 }
10101112 m := res.Msg.GetMergeRequest()
10111113 deleted := res.Msg.GetSourceBranchDeleted()
1012- if deleteBranch && !deleted {
1013- _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "ermite: warning: the branch %s was kept (it moved after the merge, or the server cannot delete it)\n",
1014- clean(m.GetSourceBranch()))
1015- }
10161114 if *asJSON {
10171115 return writeJSON(cmd.OutOrStdout(), jsonMerged{jsonMergeRequest: toJSONMergeRequest(m, c.URL()), SourceBranchDeleted: deleted})
10181116 }
1019- _, err = fmt.Fprintf(cmd.OutOrStdout(), "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
1117+ out := cmd.OutOrStdout()
1118+ _, err = fmt.Fprintf(out, "merged %s!%d into %s (%s) at %s\n", clean(m.GetRepo()), m.GetNumber(),
10201119 clean(m.GetTargetBranch()), modeName(m.GetMergeMode()), shortID(m.GetMergedCommitId()))
1120+ if err == nil && pbMode != ermite.MergeMode_MERGE_MODE_FAST_FORWARD {
1121+ _, err = fmt.Fprintln(out, signedNote(ctx, c))
1122+ }
10211123 if err == nil && deleted {
1022- _, err = fmt.Fprintf(cmd.OutOrStdout(), "deleted branch %s\n", clean(m.GetSourceBranch()))
1124+ _, err = fmt.Fprintf(out, "deleting branch %s (done by the server after the merge)\n", clean(m.GetSourceBranch()))
10231125 }
10241126 return err
10251127 }
10261128 return cmd
10271129 }
10281130
1131+// signedNote says that a squash or merge commit is signed by the server's
1132+// key, with its fingerprint when the server tells it.
1133+func signedNote(ctx context.Context, c *client.Client) string {
1134+ const how = "; `ermite signing-key show` prints the key to verify it"
1135+ res, err := c.Server().GetSigningKey(ctx, connect.NewRequest(&ermite.GetSigningKeyRequest{}))
1136+ if err != nil || res.Msg.GetFingerprint() == "" {
1137+ return "the commit is signed by the server's key" + how
1138+ }
1139+ return "the commit is signed by the server's key " + clean(res.Msg.GetFingerprint()) + how
1140+}
1141+
1142+// requireFeature refuses to go on with a server that does not list the
1143+// feature. A server whose features cannot be read is refused too.
1144+func requireFeature(ctx context.Context, c *client.Client, feature, what string) error {
1145+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
1146+ if err != nil {
1147+ return c.Describe(err)
1148+ }
1149+ if !slices.Contains(res.Msg.GetFeatures(), feature) {
1150+ return fmt.Errorf("the server does not support %s, so it %s (upgrade the server, see `ermite version`)", feature, what)
1151+ }
1152+ return nil
1153+}
1154+
10291155 func newMRCloseCmd(env *cliEnv) *cobra.Command {
10301156 cmd := &cobra.Command{
10311157 Use: "close <repo> <number>",
M internal/app/cli_mr_e2e_test.go +6 -6
@@ -260,19 +260,19 @@ func TestMergeRequestsCLIEndToEnd(t *testing.T) {
260260 require.Error(t, res.err)
261261 assert.Contains(t, res.err.Error(), "precondition failed")
262262
263- // Squash, with --squash. Without --head the merge is refused before it reaches the server.
263+ // Squash, with --mode squash. Without --head the merge is refused before it reaches the server.
264264 c.mustGit(wt, "checkout", "-q", "-b", "feat/two", head3)
265265 c.mustGit(wt, "-c", "user.name=Cy", "-c", "user.email=cy@example.org", "commit", "-q", "--allow-empty", "-m", "empty by Cy")
266266 c.writeFile(wt, "two.txt", []byte("two\n"))
267267 c.mustGit(wt, "push", "-q", url, "feat/two")
268268 c.cli(t, secret, "mr", "create", "proj", "--source", "feat/two", "--title", "Second").ok(t)
269269 w.nextOfType(t, "merge_request_opened")
270- _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--squash")
270+ _, stderr, code = c.bin(t, secret, "mr", "merge", "proj", "2", "--mode", "squash")
271271 requireOneLineError(t, stderr, code, "--head is required", "ermite mr show proj 2")
272272 head4 := c.bareGit("proj", "rev-parse", "feat/two")
273- res = c.cli(t, secret, "mr", "merge", "proj", "2", "--squash", "--head", head4)
273+ res = c.cli(t, secret, "mr", "merge", "proj", "2", "--mode", "squash", "--head", head4)
274274 require.NoError(t, res.err, res.stderr)
275- assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\n$`, res.stdout)
275+ assert.Regexp(t, `^merged proj!2 into main \(squash\) at [0-9a-f]{10}\nthe commit is signed by the server's key SHA256:\S+; .ermite signing-key show. prints the key to verify it\n$`, res.stdout)
276276 sq := c.bareGit("proj", "rev-parse", "main")
277277 assert.Equal(t, head3, c.bareGit("proj", "rev-parse", sq+"^"))
278278 assert.Equal(t, "Second (!2)", c.bareGit("proj", "log", "-1", "--format=%s", sq))
@@ -338,7 +338,7 @@ func TestSigningKeyShowVerifiesASquashCommit(t *testing.T) {
338338 c.writeFile(wt, "x.txt", []byte("x\n"))
339339 c.mustGit(wt, "push", "-q", url, "main", "feat")
340340 c.cli(t, adminToken, "mr", "create", "proj", "--source", "feat", "--title", "Add x").ok(t)
341- res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
341+ res := c.cli(t, adminToken, "mr", "merge", "proj", "1", "--mode", "squash", "--head", c.bareGit("proj", "rev-parse", "feat"))
342342 require.NoError(t, res.err, res.stderr)
343343 sq := c.bareGit("proj", "rev-parse", "main")
344344
@@ -381,7 +381,7 @@ func TestRepoEditMergeSettingsCLI(t *testing.T) {
381381 MergeModes []string `json:"merge_modes"`
382382 FailingChecksBlock bool `json:"failing_checks_block"`
383383 }](t, res.stdout)
384- assert.Equal(t, []string{"fast-forward", "squash"}, repo.MergeModes)
384+ assert.Equal(t, []string{"fast-forward", "squash", "merge"}, repo.MergeModes)
385385 assert.True(t, repo.FailingChecksBlock)
386386
387387 res = c.cli(t, adminToken, "repo", "edit", "proj", "--merge-modes", "squash", "--json").ok(t)
M internal/app/cli_mr_test.go +83 -7
@@ -3,6 +3,8 @@
33 package app
44
55 import (
6+ "encoding/json"
7+ "regexp"
68 "strings"
79 "testing"
810
@@ -174,6 +176,53 @@ func TestMRShow(t *testing.T) {
174176 assert.Contains(t, res.stderr, "head you last read is gone")
175177 }
176178
179+// The mergeability of an open request is a line of `mr show` and a field of
180+// its JSON.
181+func TestMRShowMergeability(t *testing.T) {
182+ withState := func(mg *ermite.Mergeability) *ermite.GetMergeRequestResponse {
183+ m := proto.Clone(mrOne).(*ermite.MergeRequest)
184+ m.Mergeability = mg
185+ return &ermite.GetMergeRequestResponse{MergeRequest: m}
186+ }
187+ var cur *ermite.GetMergeRequestResponse
188+ f := &fakeAPI{getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) { return cur, nil }}
189+ url := f.serve(t)
190+ for _, tt := range []struct {
191+ mg *ermite.Mergeability
192+ line string
193+ }{
194+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE}, "up to date"},
195+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 1000, BehindCapped: true}, "behind by 1000+"},
196+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b/c.go"}, ConflictPathsTruncated: true}, "conflict (a.txt, b/c.go, ...)"},
197+ {&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CHECKING}, "checking"},
198+ } {
199+ cur = withState(tt.mg)
200+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
201+ assert.Regexp(t, `(?m)^mergeability\s+.*`+regexp.QuoteMeta(tt.line), res.stdout)
202+ }
203+ cur = withState(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2, TargetId: headA})
204+ res := runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
205+ var got struct {
206+ Mergeability *struct {
207+ State string `json:"state"`
208+ Behind int `json:"behind"`
209+ TargetID string `json:"target_id"`
210+ } `json:"mergeability"`
211+ }
212+ require.NoError(t, json.Unmarshal([]byte(res.stdout), &got))
213+ require.NotNil(t, got.Mergeability)
214+ assert.Equal(t, "behind", got.Mergeability.State)
215+ assert.Equal(t, 2, got.Mergeability.Behind)
216+ assert.Equal(t, headA, got.Mergeability.TargetID)
217+
218+ // No mergeability (a merged request, or an older server): no line, null.
219+ cur = withState(nil)
220+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1").ok(t)
221+ assert.NotContains(t, res.stdout, "mergeability")
222+ res = runCLI(t, envFor(url), "", "mr", "show", "alpha", "1", "--json").ok(t)
223+ assert.Regexp(t, `"mergeability":\s*null`, res.stdout)
224+}
225+
177226 func TestMRShowMarkRead(t *testing.T) {
178227 f := &fakeAPI{
179228 getMR: func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error) {
@@ -252,6 +301,12 @@ func TestMRResolveMergeAndClose(t *testing.T) {
252301 closeMR: func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error) {
253302 return &ermite.CloseMergeRequestResponse{MergeRequest: closed}, nil
254303 },
304+ getSigningKey: func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error) {
305+ return &ermite.GetSigningKeyResponse{Fingerprint: "SHA256:abc"}, nil
306+ },
307+ getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
308+ return &ermite.GetVersionResponse{Version: "test", Features: []string{"merge_requests.mergeability"}}, nil
309+ },
255310 }
256311 url := f.serve(t)
257312
@@ -265,16 +320,37 @@ func TestMRResolveMergeAndClose(t *testing.T) {
265320 assert.False(t, reqs[1].(*ermite.ResolveCommentRequest).GetResolved())
266321
267322 // Merge with an explicit head and mode.
268- res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).ok(t)
269- assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n", res.stdout)
323+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "squash", "--head", headA).ok(t)
324+ assert.Equal(t, "merged alpha!1 into main (squash) at dddddddddd\n"+
325+ "the commit is signed by the server's key SHA256:abc; `ermite signing-key show` prints the key to verify it\n", res.stdout)
270326 assert.Empty(t, res.stderr)
271327 calls, reqs := f.snapshot()
272- assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest"}, calls, "an explicit head needs no read first")
328+ assert.Equal(t, []string{"ResolveComment", "ResolveComment", "MergeMergeRequest", "GetSigningKey"}, calls, "an explicit head needs no read first; the signing key is for the note")
273329 m := reqs[2].(*ermite.MergeMergeRequestRequest)
274330 assert.Equal(t, ermite.MergeMode_MERGE_MODE_SQUASH, m.GetMode())
275331 assert.Equal(t, headA, m.GetHeadId())
276332 assert.EqualValues(t, 1, m.GetNumber())
277333
334+ // A merge commit, and a mode that does not exist.
335+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_MERGE_COMMIT
336+ res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "merge", "--head", headA).ok(t)
337+ assert.Contains(t, res.stdout, "merged alpha!1 into main (merge) at dddddddddd\n")
338+ assert.Contains(t, res.stdout, "signed by the server's key SHA256:abc")
339+ _, reqs = f.snapshot()
340+ var lastMerge *ermite.MergeMergeRequestRequest
341+ for _, r := range reqs {
342+ if mr, ok := r.(*ermite.MergeMergeRequestRequest); ok {
343+ lastMerge = mr
344+ }
345+ }
346+ require.NotNil(t, lastMerge)
347+ assert.Equal(t, ermite.MergeMode_MERGE_MODE_MERGE_COMMIT, lastMerge.GetMode())
348+ bad := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--mode", "rebase", "--head", headA)
349+ require.Error(t, bad.err)
350+ assert.Contains(t, bad.err.Error(), "invalid merge mode")
351+ assert.Error(t, runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--squash", "--head", headA).err, "--squash is gone")
352+ merged.MergeMode = ermite.MergeMode_MERGE_MODE_SQUASH
353+
278354 // The default mode is a fast-forward.
279355 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--json").ok(t)
280356 assert.Equal(t, "merged", decode[mrOut](t, res.stdout).State)
@@ -299,7 +375,7 @@ func TestMRMergeNeedsTheHead(t *testing.T) {
299375 url := f.serve(t)
300376 for _, args := range [][]string{
301377 {"mr", "merge", "alpha", "1"},
302- {"mr", "merge", "alpha", "1", "--squash"},
378+ {"mr", "merge", "alpha", "1", "--mode", "squash"},
303379 {"mr", "merge", "alpha", "1", "--head", " "},
304380 } {
305381 res := runCLI(t, envFor(url), "", args...)
@@ -326,7 +402,7 @@ func TestMRMergeDeleteBranch(t *testing.T) {
326402 url := f.serve(t)
327403
328404 res := runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
329- assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleted branch feat/x\n", res.stdout)
405+ assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\ndeleting branch feat/x (done by the server after the merge)\n", res.stdout)
330406 assert.Empty(t, res.stderr)
331407 _, reqs := f.snapshot()
332408 assert.True(t, reqs[0].(*ermite.MergeMergeRequestRequest).GetDeleteSourceBranch())
@@ -334,11 +410,11 @@ func TestMRMergeDeleteBranch(t *testing.T) {
334410 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch", "--json").ok(t)
335411 assert.Contains(t, res.stdout, `"source_branch_deleted": true`)
336412
337- // The merge stands when the branch is kept: a warning, no failure.
413+ // Deletion is asynchronous: the CLI makes no claim when the server did not
414+ // request it.
338415 deleted = false
339416 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA, "--delete-branch").ok(t)
340417 assert.Equal(t, "merged alpha!1 into main (fast-forward) at "+headA[:10]+"\n", res.stdout)
341- assert.Contains(t, res.stderr, "ermite: warning: the branch feat/x was kept")
342418
343419 // Without the flag nothing is asked and nothing is said.
344420 res = runCLI(t, envFor(url), "", "mr", "merge", "alpha", "1", "--head", headA).ok(t)
M internal/app/cli_repo.go +9 -7
@@ -33,7 +33,7 @@ type jsonRepo struct {
3333 DefaultBranch string `json:"default_branch"`
3434 CreatedAt *string `json:"created_at"`
3535 PushedAt *string `json:"pushed_at"`
36- // MergeModes are the allowed merge modes ("fast-forward", "squash").
36+ // MergeModes are the allowed merge modes ("fast-forward", "squash", "merge").
3737 MergeModes []string `json:"merge_modes"`
3838 FailingChecksBlock bool `json:"failing_checks_block"`
3939 // Issues is the issue mode ("off", "owner" or "public").
@@ -114,8 +114,8 @@ func parseVisibility(s string) (ermite.Visibility, error) {
114114 const jsonRepoHelp = `
115115 JSON fields of a repository (--json): name, description, visibility ("public" or
116116 "private"), default_branch, created_at and pushed_at (RFC 3339 UTC; pushed_at is
117-null until the first push), merge_modes (array of the allowed merge modes, "fast-forward" and/or
118-"squash"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
117+null until the first push), merge_modes (array of the allowed merge modes, "fast-forward", "squash" and/or
118+"merge"), failing_checks_block (whether a failing check prevents a merge) and issues (the issue
119119 mode: "off", "owner" or "public").`
120120
121121 func newRepoCmd(env *cliEnv) *cobra.Command {
@@ -315,7 +315,9 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
315315 Short: "Change a repository's description, topics, visibility, default branch, merge settings or issues",
316316 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
317317 "description and --topics \"\" clears the topics. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
318- "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
318+ "may be merged with (fast-forward, squash, merge; every mode is allowed by default, so to disable one,\n" +
319+ "list the others: --merge-modes fast-forward,squash disables the merge commit; squash and merge\n" +
320+ "commits are signed with the server's key, see `ermite signing-key show`), and\n" +
319321 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320322 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321323 "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
@@ -327,7 +329,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
327329 cmd.Flags().StringSliceVar(&topics, "topics", nil, "new topics, comma-separated (empty clears them)")
328330 cmd.Flags().StringVar(&visibility, "visibility", "", "new visibility: public or private")
329331 cmd.Flags().StringVar(&defaultBranch, "default-branch", "", "short name of the new default branch")
330- cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash")
332+ cmd.Flags().StringSliceVar(&mergeModes, "merge-modes", nil, "allowed merge modes, comma-separated: fast-forward, squash, merge (all by default; list the ones to keep)")
331333 cmd.Flags().BoolVar(&failingChecksBlock, "failing-checks-block", true, "a failing check prevents a merge (--failing-checks-block=false to allow it)")
332334 cmd.Flags().StringVar(&issues, "issues", "", "issue mode: off, owner or public")
333335 _ = cmd.RegisterFlagCompletionFunc("issues", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
@@ -335,7 +337,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
335337 })
336338 _ = cmd.RegisterFlagCompletionFunc("visibility", visibilityCompletion)
337339 _ = cmd.RegisterFlagCompletionFunc("merge-modes", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
338- return []string{modeFastForward, modeSquash}, cobra.ShellCompDirectiveNoFileComp
340+ return []string{modeFastForward, modeSquash, modeMerge}, cobra.ShellCompDirectiveNoFileComp
339341 })
340342 asJSON := addJSONFlag(cmd)
341343 cmd.RunE = func(cmd *cobra.Command, args []string) error {
@@ -400,7 +402,7 @@ func buildUpdateRepo(cmd *cobra.Command, name, description string, topics []stri
400402 modes = append(modes, mode)
401403 }
402404 if len(modes) == 0 {
403- return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash")
405+ return nil, errors.New("--merge-modes must name at least one mode: fast-forward, squash, merge")
404406 }
405407 req.Settings = &ermite.RepoSettings{AllowedMergeModes: modes}
406408 paths = append(paths, "settings.allowed_merge_modes")
M internal/app/cli_version.go +1 -0
@@ -114,6 +114,7 @@ var featureUse = map[string]string{
114114 client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115115 client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116116 client.FeatureIssues: "`ermite issue` needs: the commands will fail on this server, and `repo edit --issues` and `token create --scope issue:write` are refused",
117+ client.FeatureMergeability: "`ermite mr show` uses to show whether the request merges: the line is missing",
117118 client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
118119 client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
119120 client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, issues, merge_requests.mergeability, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.workflows, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.workflows", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","issues","merge_requests.mergeability","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.workflows","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/cli_watch.go +30 -0
@@ -29,6 +29,8 @@ type jsonEvent struct {
2929 MergeRequest *jsonMergeRequest `json:"merge_request,omitempty"`
3030 Number int64 `json:"number,omitempty"`
3131 Comment *jsonComment `json:"comment,omitempty"`
32+ // Mergeability is set for a merge_request_mergeability event.
33+ Mergeability *jsonMergeability `json:"mergeability,omitempty"`
3234 }
3335
3436 type jsonRefUpdate struct {
@@ -52,6 +54,7 @@ const (
5254 eventCommentEdited = "comment_edited"
5355 eventCommentDeleted = "comment_deleted"
5456 eventCommentResolved = "comment_resolved"
57+ eventMRMergeability = "merge_request_mergeability"
5558 )
5659
5760 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -94,6 +97,9 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
9497 case *ermite.Event_CommentResolved:
9598 c := toJSONComment(k.CommentResolved.GetComment())
9699 out.Type, out.Repo, out.Number, out.Comment = eventCommentResolved, k.CommentResolved.GetRepo(), k.CommentResolved.GetNumber(), &c
100+ case *ermite.Event_MergeRequestMergeability:
101+ out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102+ out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
97103 case *ermite.Event_Gap:
98104 out.Type = eventGap
99105 default:
@@ -126,12 +132,36 @@ func eventLine(e jsonEvent) string {
126132 detail = fmt.Sprintf("%s!%d %s -> %s", e.Repo, e.MergeRequest.Number, e.MergeRequest.SourceBranch, e.MergeRequest.TargetBranch)
127133 case eventCommentAdded, eventCommentEdited, eventCommentDeleted, eventCommentResolved:
128134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135+ case eventMRMergeability:
136+ detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
129137 case eventGap:
130138 detail = "events were lost, reload what you display"
131139 }
132140 return clean(ts + " " + e.Type + " " + detail)
133141 }
134142
143+// mergeabilityEventText is the short form of a mergeability for a watch line:
144+// "up_to_date", "behind by 2", "conflict (a, b)", "checking".
145+func mergeabilityEventText(m *jsonMergeability) string {
146+ if m == nil {
147+ return "unknown"
148+ }
149+ switch m.State {
150+ case "behind":
151+ return fmt.Sprintf("behind by %d%s", m.Behind, plus(m.BehindCapped))
152+ case "conflict":
153+ if len(m.ConflictPaths) == 0 {
154+ return "conflict (no common history)"
155+ }
156+ more := ""
157+ if m.ConflictPathsTruncated {
158+ more = ", ..."
159+ }
160+ return "conflict (" + strings.Join(m.ConflictPaths, ", ") + more + ")"
161+ }
162+ return m.State
163+}
164+
135165 func refUpdateText(u jsonRefUpdate) string {
136166 short := func(id string) string {
137167 if len(id) > 8 {
M internal/app/cli_watch_test.go +43 -0
@@ -200,6 +200,49 @@ func TestWatchMergeRequestEvents(t *testing.T) {
200200 assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201201 }
202202
203+// A mergeability event shows the state, with how far behind or the paths.
204+func TestWatchMergeabilityEvents(t *testing.T) {
205+ mg := func(m *ermite.Mergeability) func(*ermite.Event) {
206+ return func(e *ermite.Event) {
207+ e.Kind = &ermite.Event_MergeRequestMergeability{MergeRequestMergeability: &ermite.MergeRequestMergeability{Repo: "alpha", Number: 1, Mergeability: m}}
208+ }
209+ }
210+ events := []*ermite.WatchResponse{
211+ evt("1", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_UP_TO_DATE})),
212+ evt("2", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_BEHIND, Behind: 2})),
213+ evt("3", mg(&ermite.Mergeability{State: ermite.MergeabilityState_MERGEABILITY_STATE_CONFLICT, ConflictPaths: []string{"a.txt", "b.txt"}})),
214+ }
215+ f := &fakeAPI{watch: finishing(events)}
216+ url := f.serve(t)
217+
218+ res := runCLI(t, envFor(url), "", "watch")
219+ require.Error(t, res.err)
220+ assert.Equal(t, ""+
221+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: up_to_date\n"+
222+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: behind by 2\n"+
223+ "2026-10-10T12:00:00Z merge_request_mergeability alpha!1 mergeability: conflict (a.txt, b.txt)\n", res.stdout)
224+
225+ res = runCLI(t, envFor(url), "", "watch", "--json")
226+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
227+ require.Len(t, lines, 3)
228+ var ev struct {
229+ Type string `json:"type"`
230+ Number int64 `json:"number"`
231+ Mergeability struct {
232+ State string `json:"state"`
233+ Behind int `json:"behind"`
234+ ConflictPaths []string `json:"conflict_paths"`
235+ } `json:"mergeability"`
236+ }
237+ require.NoError(t, json.Unmarshal([]byte(lines[1]), &ev))
238+ assert.Equal(t, "merge_request_mergeability", ev.Type)
239+ assert.EqualValues(t, 1, ev.Number)
240+ assert.Equal(t, "behind", ev.Mergeability.State)
241+ assert.Equal(t, 2, ev.Mergeability.Behind)
242+ require.NoError(t, json.Unmarshal([]byte(lines[2]), &ev))
243+ assert.Equal(t, []string{"a.txt", "b.txt"}, ev.Mergeability.ConflictPaths)
244+}
245+
203246 func TestNoteWatchEvents(t *testing.T) {
204247 note := &ermite.Comment{Id: "4", ReplyTo: "2", StartLine: 10, Line: 24, Verdict: ermite.Verdict_VERDICT_APPROVE, EditedAt: ts("2026-10-10T12:00:00Z"), Deleted: true, Author: "owner"}
205248 events := []*ermite.Event{
M internal/app/forge.go +14 -1
@@ -137,6 +137,8 @@ type forge struct {
137137 // Inbox serves the owner's inbox of the web.
138138 Inbox *usecase.Inbox
139139 SigningKey *usecase.SigningKey
140+ // Mergeability keeps the mergeability of the open merge requests up to date.
141+ Mergeability *usecase.Mergeability
140142 // Signatures are the trusted keys and the commit signature checks
141143 // (ADR 0030).
142144 Signatures *usecase.Signatures
@@ -331,11 +333,16 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
331333 if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
332334 return nil, err
333335 }
336+ f.Mergeability = usecase.NewMergeability(ctx, usecase.MergeabilityConfig{
337+ Store: store.MergeRequests(), Meta: store, Git: git, Bus: f.Bus, Clock: clock,
338+ })
339+ f.Pushes.WithMergeability(f.Mergeability)
334340 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
335341 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
336342 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
337- CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(),
343+ CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
338344 })
345+ f.Mergeability.Start()
339346 f.MergeRequests.WithVerifier(f.Signatures)
340347 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
341348 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
@@ -512,9 +519,15 @@ func (f *forge) Close(ctx context.Context) {
512519 if f.hooks != nil {
513520 f.hooks.Close(ctx)
514521 }
522+ if f.MergeRequests != nil {
523+ f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
524+ }
515525 if f.CI != nil {
516526 f.CI.Close(ctx)
517527 }
528+ if f.Mergeability != nil {
529+ f.Mergeability.Close(ctx)
530+ }
518531 if f.EventLog != nil {
519532 f.EventLog.Close(ctx)
520533 }
M internal/app/mr_api_test.go +3 -2
@@ -298,7 +298,7 @@ func TestUpdateRepoSettingsOverTheAPI(t *testing.T) {
298298
299299 got, err := h.api("").repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "proj"}))
300300 require.NoError(t, err)
301- assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
301+ assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, got.Msg.GetRepo().GetSettings().GetAllowedMergeModes())
302302 assert.True(t, got.Msg.GetRepo().GetSettings().GetFailingChecksBlock())
303303
304304 update := func(c *clients, paths []string, s *pb.RepoSettings) (*pb.Repo, error) {
@@ -375,7 +375,8 @@ func TestMergeDeletesTheSourceOverTheAPI(t *testing.T) {
375375 Repo: "proj", Number: 1, HeadId: r.head, DeleteSourceBranch: true,
376376 }))
377377 require.NoError(t, err)
378- assert.True(t, res.Msg.GetSourceBranchDeleted())
378+ assert.True(t, res.Msg.GetSourceBranchDeleted(), "requested")
379+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the response
379380 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat"))
380381
381382 // The default branch as a source: refused before anything changes.
M internal/app/testdata/web/commits-root.html +1 -1
@@ -972,7 +972,7 @@
972972 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
975+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976976 gap -> ermiteLive.reload()">
977977 </span>
978978 </div>
M internal/app/testdata/web/commits-small.html +1 -1
@@ -317,7 +317,7 @@
317317 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
320+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321321 gap -> ermiteLive.reload()">
322322 </span>
323323 </div>
M internal/app/testdata/web/home.html +1 -1
@@ -191,7 +191,7 @@
191191 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
192192 htmx:sse:error -> ermiteLive.error(event.detail.status);
193193 htmx:sse:before:connection -> ermiteLive.gate(event);
194- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
194+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
195195 gap -> ermiteLive.reload()">
196196 </span>
197197 </div>
M internal/app/testdata/web/refs.html +1 -1
@@ -199,7 +199,7 @@
199199 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
202+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203203 gap -> ermiteLive.reload()">
204204 </span>
205205 </div>
M internal/app/testdata/web/summary.html +1 -1
@@ -225,7 +225,7 @@
225225 htmx:sse:close, htmx:sse:error -> ermiteLive.lost();
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228- home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229229 gap -> ermiteLive.reload()">
230230 </span>
231231 </div>
M internal/app/web.go +5 -0
@@ -35,6 +35,10 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3535 if f.Passkeys != nil {
3636 passkeys = webPasskeys{f.Passkeys}
3737 }
38+ var signing ports.SigningKeyService
39+ if f.SigningKey != nil {
40+ signing = f.SigningKey
41+ }
3842 // Without artifacts (ERMITE_CI_ARTIFACTS=0) the run page has no section
3943 // for them and no download route.
4044 var artifacts ports.ArtifactService
@@ -50,6 +54,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5054 Inbox: f.Inbox,
5155 IssueForm: f.IssueSubmissions,
5256 Moderation: f.IssueModeration,
57+ SigningKey: signing,
5358 Runs: f.CI,
5459 Artifacts: artifacts,
5560 Events: f.EventLog,
M internal/app/web_events_test.go +9 -0
@@ -37,6 +37,12 @@ type sseStream struct {
3737 // background. It returns once the server answered, which it does when the
3838 // watch is registered: what happens after is not missed.
3939 func (h *harness) sse(query, lastID string) *sseStream {
40+ h.t.Helper()
41+ return h.sseAs(query, lastID, nil)
42+}
43+
44+// sseAs opens the stream with the headers given (the owner's cookie).
45+func (h *harness) sseAs(query, lastID string, headers map[string]string) *sseStream {
4046 h.t.Helper()
4147 ctx, cancel := context.WithCancel(h.ctx)
4248 h.t.Cleanup(cancel)
@@ -45,6 +51,9 @@ func (h *harness) sse(query, lastID string) *sseStream {
4551 if lastID != "" {
4652 req.Header.Set("Last-Event-ID", lastID)
4753 }
54+ for k, v := range headers {
55+ req.Header.Set(k, v)
56+ }
4857 resp, err := http.DefaultClient.Do(req)
4958 require.NoError(h.t, err)
5059 t := h.t
M internal/app/web_mr_test.go +202 -10
@@ -10,6 +10,7 @@ import (
1010 "regexp"
1111 "strings"
1212 "testing"
13+ "time"
1314
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
@@ -165,12 +166,15 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
165166 assert.Equal(t, head, formHead(t, own.body))
166167 assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`)
167168 assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff")
168- assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default")
169- assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`)
169+ assert.Contains(t, own.body, `name="mode" value="fast-forward" checked`, "the first possible mode is the default")
170+ assert.Contains(t, own.body, `name="mode" value="squash"`)
171+ assert.Contains(t, own.body, `name="mode" value="merge"`, "every mode is allowed by default")
170172 assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head)
171173 assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`)
172174 assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default")
173- assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash")
175+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode squash")
176+ assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --mode merge")
177+ assert.NotContains(t, own.body, "--squash")
174178 assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`)
175179 assert.Contains(t, own.body, `action="/proj/merge-requests/1/close"`)
176180 assert.Contains(t, own.body, `href="/proj/merge-requests/1?on=new:1:search.go#f`, "line numbers link to their form")
@@ -392,6 +396,7 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
392396 assert.Contains(t, b.get("/proj/merge-requests/1", as(token)).body, `name="delete_branch" value="1"`, "the box offers to delete the branch")
393397 res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}, "delete_branch": {"1"}})
394398 require.Equal(t, http.StatusSeeOther, res.status, res.body)
399+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
395400 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"), "the source branch is deleted")
396401 merged := r.headOf("main")
397402 assert.NotEqual(t, head, merged, "squash makes a new commit")
@@ -461,6 +466,9 @@ func TestWebMergeRequestDraft(t *testing.T) {
461466
462467 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
463468 assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
469+ assert.Contains(t, res.body, `data-canonical="/proj/merge-requests/1"`, "the refused page names its own address")
470+ assert.Contains(t, res.body, `class="d-err"`, "the reason stays inline")
471+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "data-canonical", "the plain page has no marker")
464472
465473 res = b.form("/proj/merge-requests/1/ready", token, nil)
466474 require.Equal(t, http.StatusSeeOther, res.status, res.body)
@@ -511,6 +519,7 @@ func TestWebMergeDeleteBranchBox(t *testing.T) {
511519 one := b.get("/proj/merge-requests/1", as(token))
512520 res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {formHead(t, one.body)}, "delete_branch": {"1"}})
513521 require.Equal(t, http.StatusSeeOther, res.status, res.body)
522+ r.forge.MergeRequests.Wait(r.ctx) // the deletion follows the redirect
514523 assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
515524 done := b.get("/proj/merge-requests/1", nil)
516525 assert.Equal(t, 200, done.status, "the merged page reads without its branch")
@@ -569,8 +578,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
569578 r := newMRRepo(t, domain.VisibilityPublic)
570579 list := r.sse("topic=repo:proj&view=mrs", "")
571580 r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Secret <title>").ok(t)
572- ev := list.next(t, watchWait)
573- assert.Equal(t, "mr-notice", ev.name)
581+ ev := nextNamed(t, list, "mr-notice")
574582 assert.Contains(t, ev.data, `<div id="mr-notice" class="live-slot" role="status" data-slot>`)
575583 assert.Contains(t, ev.data, `href="/proj/merge-requests"`)
576584 assert.NotContains(t, ev.data, "Secret")
@@ -578,19 +586,18 @@ func TestWebMergeRequestEvents(t *testing.T) {
578586 one := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search", "")
579587 other := r.sse("topic=repo:proj&view=mr&n=2&ref=feat/other", "")
580588 r.cli(t, adminToken, "mr", "comment", "proj", "1", "-m", "private words").ok(t)
581- ev = one.next(t, watchWait)
582- assert.Equal(t, "mr-notice", ev.name)
589+ ev = nextNamed(t, one, "mr-notice")
583590 assert.Contains(t, ev.data, "a comment was added")
584591 assert.Contains(t, ev.data, `href="/proj/merge-requests/1"`)
585592 assert.NotContains(t, ev.data, "private words")
586593
587594 r.writeFile(r.wt, "more.txt", []byte("more\n"))
588595 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
589- ev = one.next(t, watchWait)
596+ ev = nextNamed(t, one, "mr-notice")
590597 assert.Contains(t, ev.data, "the branch changed")
591598
592599 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
593- assert.Contains(t, one.next(t, watchWait).data, "was closed")
600+ assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
594601
595602 // A page of another request hears nothing of this one.
596603 for drained := false; !drained; {
@@ -603,7 +610,20 @@ func TestWebMergeRequestEvents(t *testing.T) {
603610 drained = true
604611 }
605612 }
606- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
613+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`&amp;target=main"`)
614+}
615+
616+// nextNamed returns the next event that carries the fragment event: the
617+// events that change nothing on the page (an id alone) are skipped.
618+func nextNamed(t *testing.T, s *sseStream, name string) sseEvent {
619+ t.Helper()
620+ for range 10 {
621+ if ev := s.next(t, watchWait); ev.name == name {
622+ return ev
623+ }
624+ }
625+ t.Fatalf("no %s event", name)
626+ return sseEvent{}
607627 }
608628
609629 func (r *mrRepo) get1(path string) string {
@@ -722,3 +742,175 @@ func TestWebMergeRequestFileWindows(t *testing.T) {
722742 }
723743
724744 var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`)
745+
746+// advanceMain pushes a commit to main that the feature branch lacks. file is
747+// the file it adds (give search.go to conflict with the feature branch).
748+func (r *mrRepo) advanceMain(file, content string) {
749+ r.t.Helper()
750+ r.mustGit(r.wt, "checkout", "-q", "main")
751+ r.writeFile(r.wt, file, []byte(content))
752+ r.mustGit(r.wt, "push", "-q", r.url, "main")
753+ r.mustGit(r.wt, "checkout", "-q", "feat/search")
754+}
755+
756+// mrPageWith polls the owner's page of !1 until it contains want.
757+func (r *mrRepo) mrPageWith(token, want string) string {
758+ r.t.Helper()
759+ var body string
760+ require.Eventually(r.t, func() bool {
761+ body = browser{r.harness}.get("/proj/merge-requests/1", as(token)).body
762+ return strings.Contains(body, want)
763+ }, 10*time.Second, 25*time.Millisecond, "the page never showed %q", want)
764+ return body
765+}
766+
767+// The merge box shows the mergeability of the request: ready, then behind
768+// (fast-forward is disabled with its reason, the other modes are not), then
769+// in conflict (no mode is possible and the button says why).
770+func TestWebMergeRequestMergeabilityBadge(t *testing.T) {
771+ r := newMRRepo(t, domain.VisibilityPublic)
772+ token := r.signIn(t)
773+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
774+
775+ body := r.mrPageWith(token, "Ready to merge")
776+ assert.Contains(t, body, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
777+ assert.Contains(t, body, `data-state="up_to_date"`)
778+ assert.Contains(t, body, `name="mode" value="fast-forward" checked`)
779+ assert.NotContains(t, body, `value="fast-forward" checked disabled`)
780+ assert.Contains(t, body, `id="mr-signing"`, "squash and merge commits say who signs them")
781+ assert.Contains(t, body, "signed with ermite's own key (generated on first start)")
782+ assert.Contains(t, body, "<code>ermite signing-key show</code>")
783+ assert.Contains(t, body, "SHA256:", "the fingerprint of the signing key")
784+ assert.Contains(t, body, `:disabled="`, "the controls follow the badge")
785+ assertNoInline(t, body)
786+
787+ // Main moves on: the branch is behind by one commit and merges cleanly.
788+ r.advanceMain("other.txt", "other\n")
789+ body = r.mrPageWith(token, "Behind main by 1 commit")
790+ assert.Contains(t, body, "merges without conflicts")
791+ assert.Contains(t, body, `data-ff-ok="false"`)
792+ assert.Contains(t, body, `data-merge-ok="true"`)
793+ assert.Regexp(t, `<input type="radio" name="mode" value="fast-forward"[^>]* disabled`, body)
794+ assert.Contains(t, body, `title="behind the target: rebase or use squash/merge"`)
795+ assert.Regexp(t, `name="mode" value="squash" checked`, body, "the first possible mode is checked")
796+ assert.NotRegexp(t, `name="mode" value="squash"[^>]* disabled`, body)
797+
798+ // A conflicting change to main: every mode is disabled and the files are listed.
799+ r.mustGit(r.wt, "checkout", "-q", "main")
800+ r.writeFile(r.wt, "search.go", []byte("package other\n"))
801+ r.mustGit(r.wt, "push", "-q", r.url, "main")
802+ body = r.mrPageWith(token, "Conflicts with main")
803+ assert.Contains(t, body, "rebase needed")
804+ assert.Contains(t, body, `<ul class="mg-paths" aria-label="conflicting files"><li><code>search.go</code></li></ul>`)
805+ assert.Regexp(t, `<button type="submit" class="btn primary" data-default disabled title="conflicts with the target"`, body)
806+ assert.NotContains(t, body, `name="mode" value="squash" checked`)
807+
808+ // The server refuses it too, and says so in the box of the page.
809+ res := browser{r.harness}.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {formHead(t, body)}})
810+ assert.Equal(t, http.StatusConflict, res.status)
811+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
812+ assert.Contains(t, res.body, "conflicts with main")
813+}
814+
815+// A merge the server refuses (the target moved after the page was read) shows
816+// the page of the request again, 409, with the reason in its merge box.
817+func TestWebMergeRequestInlineMergeError(t *testing.T) {
818+ r := newMRRepo(t, domain.VisibilityPublic)
819+ b := browser{r.harness}
820+ token := r.signIn(t)
821+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
822+ body := r.mrPageWith(token, "Ready to merge")
823+ head := formHead(t, body)
824+
825+ r.advanceMain("other.txt", "other\n") // raced: the form below still says fast-forward
826+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
827+ assert.Equal(t, http.StatusConflict, res.status)
828+ assert.Equal(t, "no-store", res.hdr.Get("Cache-Control"))
829+ assert.Contains(t, res.body, `id="merge-menu"`, "the page of the request, not a state page")
830+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
831+ assert.Contains(t, res.body, "the target has moved")
832+ assert.Contains(t, res.body, `<details class="menu mmenu" id="merge-menu" data-state="open" open>`, "the box is open on the error")
833+ assert.Equal(t, head, r.headOf("feat/search"))
834+ assert.NotContains(t, b.get("/proj/merge-requests/1", as(token)).body, "d-err", "a reload shows no error")
835+
836+ // A head that moved is refused the same way.
837+ r.writeFile(r.wt, "more.txt", []byte("more\n"))
838+ r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
839+ res = b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"squash"}, "head_id": {head}})
840+ assert.Equal(t, http.StatusConflict, res.status)
841+ assert.Contains(t, res.body, `<p class="d-err" role="alert">`)
842+ assert.Contains(t, res.body, "branch moved")
843+
844+ // Other refusals keep the state page; a mode that does not exist is a 400.
845+ assert.Equal(t, http.StatusBadRequest, b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"rebase"}, "head_id": {head}}).status)
846+}
847+
848+// A merge commit through the page; with the branch deletion requested the
849+// merged page says the branch is being deleted, not that it is gone.
850+func TestWebMergeRequestMergeCommitAndDeletingNote(t *testing.T) {
851+ r := newMRRepo(t, domain.VisibilityPublic)
852+ b := browser{r.harness}
853+ token := r.signIn(t)
854+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
855+ r.advanceMain("other.txt", "other\n")
856+ body := r.mrPageWith(token, "Behind main by 1 commit")
857+ head := formHead(t, body)
858+ main0 := r.headOf("main")
859+
860+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"merge"}, "head_id": {head}, "delete_branch": {"1"}})
861+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
862+ assert.Equal(t, "/proj/merge-requests/1?deleting=1", res.hdr.Get("Location"))
863+ merged := r.headOf("main")
864+ assert.Equal(t, main0, r.bareGit("proj", "rev-parse", merged+"^1"))
865+ assert.Equal(t, head, r.bareGit("proj", "rev-parse", merged+"^2"))
866+ assert.Contains(t, r.bareGit("proj", "cat-file", "-p", merged), "gpgsig")
867+ page := b.get("/proj/merge-requests/1?deleting=1", as(token))
868+ assert.Contains(t, page.body, "merge commit")
869+ if strings.Contains(page.body, "is being deleted") {
870+ assert.Contains(t, page.body, "feat/search is being deleted")
871+ }
872+ assert.NotContains(t, page.body, "is deleted")
873+ r.forge.MergeRequests.Wait(r.ctx)
874+ assert.Empty(t, r.bareGit("proj", "for-each-ref", "refs/heads/feat/search"))
875+ assert.NotContains(t, b.get("/proj/merge-requests/1?deleting=1", as(token)).body, "is being deleted", "once gone, nothing is being deleted")
876+}
877+
878+// The badge follows the request live: a push to the target turns the fragment
879+// of the page to "checking" or "behind", and the settled result follows.
880+func TestWebMergeRequestMergeabilityLive(t *testing.T) {
881+ r := newMRRepo(t, domain.VisibilityPublic)
882+ token := r.signIn(t)
883+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Add search").ok(t)
884+ r.mrPageWith(token, "Ready to merge")
885+
886+ one := r.sseAs("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "", as(token))
887+ anon := r.sse("topic=repo:proj&view=mr&n=1&ref=feat/search&target=main", "")
888+ r.advanceMain("other.txt", "other\n")
889+ deadline := time.After(watchWait)
890+ for done := false; !done; {
891+ ev := nextNamed(t, one, "mr-mergeability")
892+ select {
893+ case <-deadline:
894+ t.Fatal("the badge never showed the result")
895+ default:
896+ }
897+ if strings.Contains(ev.data, "Behind main by 1 commit") {
898+ assert.Contains(t, ev.data, `<div id="mr-mergeability" class="live-slot" role="status" data-slot>`)
899+ assert.Contains(t, ev.data, `data-ff-ok="false"`)
900+ assert.Contains(t, ev.data, `data-merge-ok="true"`)
901+ done = true
902+ } else {
903+ assert.Contains(t, ev.data, "Checking…", "before the result, the badge says it checks")
904+ }
905+ }
906+ // The box is the owner's: a visitor's stream carries no badge.
907+ for {
908+ select {
909+ case ev := <-anon.events:
910+ assert.NotEqual(t, "mr-mergeability", ev.name)
911+ continue
912+ default:
913+ }
914+ break
915+ }
916+}
M internal/domain/event.go +14 -0
@@ -284,6 +284,20 @@ type CommentResolved struct {
284284 // EventKind implements Event.
285285 func (CommentResolved) EventKind() string { return "mr.comment_resolved" }
286286
287+// MergeRequestMergeability is published when the mergeability of an open merge
288+// request changed.
289+type MergeRequestMergeability struct {
290+ Repo RepoName
291+ Number int64
292+ Mergeability Mergeability
293+ At time.Time
294+ Visibility Visibility
295+ CreatedAt time.Time
296+}
297+
298+// EventKind implements Event.
299+func (MergeRequestMergeability) EventKind() string { return "mr.mergeability" }
300+
287301 // EventGap tells a subscriber that it missed events because it was too slow;
288302 // it must refresh its whole view.
289303 type EventGap struct{}
M internal/domain/mergerequest.go +155 -16
@@ -18,18 +18,23 @@ const (
1818 MergeModeFastForward MergeMode = "fast-forward"
1919 // MergeModeSquash makes one new signed commit with the tree of the head.
2020 MergeModeSquash MergeMode = "squash"
21+ // MergeModeMerge makes one new signed commit with two parents: the target
22+ // tip and the head.
23+ MergeModeMerge MergeMode = "merge"
2124 )
2225
23-// MergeModes returns the two modes in their canonical order.
24-func MergeModes() []MergeMode { return []MergeMode{MergeModeFastForward, MergeModeSquash} }
26+// MergeModes returns the modes in their canonical order.
27+func MergeModes() []MergeMode {
28+ return []MergeMode{MergeModeFastForward, MergeModeSquash, MergeModeMerge}
29+}
2530
26-// ParseMergeMode parses "fast-forward" or "squash".
31+// ParseMergeMode parses "fast-forward", "squash" or "merge".
2732 func ParseMergeMode(s string) (MergeMode, error) {
2833 m := MergeMode(s)
2934 if slices.Contains(MergeModes(), m) {
3035 return m, nil
3136 }
32- return "", fmt.Errorf("%w: merge mode %q (want fast-forward or squash)", ErrInvalid, s)
37+ return "", fmt.Errorf("%w: merge mode %q (want fast-forward, squash or merge)", ErrInvalid, s)
3338 }
3439
3540 // String returns the mode as text.
@@ -47,7 +52,7 @@ type RepoSettings struct {
4752 Issues IssuesMode
4853 }
4954
50-// DefaultRepoSettings are the settings of a new repository: both modes, and
55+// DefaultRepoSettings are the settings of a new repository: every mode, and
5156 // failing checks block the merge.
5257 func DefaultRepoSettings() RepoSettings {
5358 return RepoSettings{AllowedMergeModes: MergeModes(), FailingChecksBlock: true, Issues: IssuesOff}
@@ -249,10 +254,10 @@ type Identity struct {
249254 Name, Email string
250255 }
251256
252-// SquashCommit is the commit a squash merge creates: the tree of the head on
253-// top of the target.
254-type SquashCommit struct {
255- Tree, Parent ObjectID
257+// ServerCommit is a commit the server creates: a squash or a merge commit.
258+type ServerCommit struct {
259+ Tree ObjectID
260+ Parents []ObjectID
256261 Author, Committer Signature
257262 Message string
258263 }
@@ -267,7 +272,7 @@ const MaxSquashCommits = 1000
267272 // trailers, in order of appearance. The message is the title, " (!<number>)",
268273 // a blank line, the summaries of the commits oldest first as a list, and the
269274 // trailers.
270-func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) SquashCommit {
275+func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, committer Identity, at time.Time) ServerCommit {
271276 oldest := slices.Clone(commits)
272277 slices.Reverse(oldest)
273278 var first Signature
@@ -293,26 +298,41 @@ func NewSquashCommit(mr MergeRequest, tree, parent ObjectID, commits []Commit, c
293298 if len(trailers) > 0 {
294299 msg.WriteString("\n" + strings.Join(trailers, "\n") + "\n")
295300 }
296- return SquashCommit{
297- Tree: tree, Parent: parent,
301+ return ServerCommit{
302+ Tree: tree, Parents: []ObjectID{parent},
298303 Author: first,
299304 Committer: Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()},
300305 Message: msg.String(),
301306 }
302307 }
303308
309+// NewMergeCommit builds the merge commit of a merge request: the tree of the
310+// merge, the target tip and the head as parents, and the server identity as
311+// author and committer.
312+func NewMergeCommit(mr MergeRequest, tree, target, head ObjectID, committer Identity, at time.Time) ServerCommit {
313+ sig := Signature{Name: committer.Name, Email: committer.Email, Time: at.UTC()}
314+ return ServerCommit{
315+ Tree: tree, Parents: []ObjectID{target, head},
316+ Author: sig, Committer: sig,
317+ Message: fmt.Sprintf("Merge branch '%s' into %s (!%d)\n\n%s\n",
318+ singleLine(mr.Source), singleLine(mr.Target), mr.Number, singleLine(mr.Title.String())),
319+ }
320+}
321+
304322 // Payload returns the text of the commit object without a signature, which is
305323 // what the signature covers.
306-func (c SquashCommit) Payload() []byte { return c.encode("") }
324+func (c ServerCommit) Payload() []byte { return c.encode("") }
307325
308326 // Signed returns the text of the commit object with the armored SSH signature
309327 // in its gpgsig header.
310-func (c SquashCommit) Signed(armored string) []byte { return c.encode(armored) }
328+func (c ServerCommit) Signed(armored string) []byte { return c.encode(armored) }
311329
312-func (c SquashCommit) encode(armored string) []byte {
330+func (c ServerCommit) encode(armored string) []byte {
313331 var b strings.Builder
314332 b.WriteString("tree " + c.Tree.String() + "\n")
315- b.WriteString("parent " + c.Parent.String() + "\n")
333+ for _, p := range c.Parents {
334+ b.WriteString("parent " + p.String() + "\n")
335+ }
316336 b.WriteString("author " + signatureLine(c.Author) + "\n")
317337 b.WriteString("committer " + signatureLine(c.Committer) + "\n")
318338 if armored != "" {
@@ -430,3 +450,122 @@ func CurrentVerdicts(comments []Comment, head ObjectID) []AuthorVerdict {
430450 })
431451 return out
432452 }
453+
454+// Limits of a stored Mergeability.
455+const (
456+ // MaxBehindCount caps the commits counted as behind.
457+ MaxBehindCount = 1000
458+ // MaxConflictPaths caps the conflicted paths listed.
459+ MaxConflictPaths = 20
460+)
461+
462+// MergeabilityState is the outcome of a mergeability check.
463+type MergeabilityState string
464+
465+// Mergeability states.
466+const (
467+ // MergeabilityChecking means there is no up-to-date result yet; it is never
468+ // stored.
469+ MergeabilityChecking MergeabilityState = "checking"
470+ // MergeabilityUpToDate means the target is an ancestor of the head.
471+ MergeabilityUpToDate MergeabilityState = "up_to_date"
472+ // MergeabilityBehind means the head lacks commits of the target but merges
473+ // cleanly.
474+ MergeabilityBehind MergeabilityState = "behind"
475+ // MergeabilityConflict means the head does not merge cleanly.
476+ MergeabilityConflict MergeabilityState = "conflict"
477+)
478+
479+// Mergeability is whether an open merge request merges with its target, for
480+// given tips.
481+type Mergeability struct {
482+ Repo RepoName
483+ Number int64
484+ State MergeabilityState
485+ // TargetID and HeadID are the tips the result is for; zero for a derived
486+ // "checking".
487+ TargetID, HeadID ObjectID
488+ // Behind counts the commits of the target not in the head (behind only),
489+ // capped at MaxBehindCount.
490+ Behind int
491+ BehindCapped bool
492+ // ConflictPaths holds at most MaxConflictPaths paths (conflict only).
493+ ConflictPaths []string
494+ ConflictPathsTruncated bool
495+ CheckedAt time.Time
496+ // Version is set by the store on read, like MergeRequest.Version.
497+ Version uint64
498+}
499+
500+// Allows reports whether the mode may be used given the state, and if not,
501+// why.
502+func (m Mergeability) Allows(mode MergeMode) (bool, string) {
503+ switch mode {
504+ case MergeModeFastForward:
505+ if m.State == MergeabilityUpToDate {
506+ return true, ""
507+ }
508+ if m.State == MergeabilityBehind {
509+ return false, "behind the target: rebase or use squash/merge"
510+ }
511+ case MergeModeSquash, MergeModeMerge:
512+ if m.State != MergeabilityConflict && m.State != MergeabilityChecking && m.State != "" {
513+ return true, ""
514+ }
515+ }
516+ switch m.State {
517+ case MergeabilityConflict:
518+ return false, "conflicts with the target"
519+ case MergeabilityChecking, "":
520+ return false, "mergeability is being checked"
521+ }
522+ return false, "mode not allowed"
523+}
524+
525+// IsFor reports whether the result is for these tips, that is not stale.
526+func (m Mergeability) IsFor(target, head ObjectID) bool {
527+ return m.State != "" && m.State != MergeabilityChecking && m.TargetID == target && m.HeadID == head
528+}
529+
530+// MergeTreeResult is the outcome of merging two commits without a commit.
531+type MergeTreeResult struct {
532+ // Tree is the merged tree; zero when not clean.
533+ Tree ObjectID
534+ Clean bool
535+ ConflictPaths []string
536+ Truncated bool
537+}
538+
539+// Bounded returns r with at most MaxConflictPaths conflicted paths, Truncated
540+// set when some were dropped. Adapters return the raw result; the bound is
541+// the domain's.
542+func (r MergeTreeResult) Bounded() MergeTreeResult {
543+ kept := r.ConflictPaths
544+ if len(kept) > MaxConflictPaths {
545+ kept = kept[:MaxConflictPaths]
546+ r.Truncated = true
547+ }
548+ if kept != nil {
549+ // Exact-sized copies: neither the backing array nor the git output the
550+ // paths were cut from stays referenced.
551+ out := make([]string, len(kept))
552+ for i, p := range kept {
553+ out[i] = strings.Clone(p)
554+ }
555+ r.ConflictPaths = out
556+ }
557+ return r
558+}
559+
560+// BehindLimit is the limit to give a count of commits behind so that
561+// BoundBehind can tell whether MaxBehindCount was exceeded.
562+const BehindLimit = MaxBehindCount + 1
563+
564+// BoundBehind caps a raw count of commits behind (counted up to BehindLimit)
565+// at MaxBehindCount; capped is true when the count was over it.
566+func BoundBehind(n int) (count int, capped bool) {
567+ if n > MaxBehindCount {
568+ return MaxBehindCount, true
569+ }
570+ return n, false
571+}
M internal/domain/mergerequest_test.go +50 -3
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strconv"
67 "strings"
78 "testing"
89 "time"
@@ -23,7 +24,7 @@ func oid(t *testing.T, c byte) domain.ObjectID {
2324 func TestDefaultRepoSettings(t *testing.T) {
2425 t.Parallel()
2526 s := domain.DefaultRepoSettings()
26- assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge}, s.AllowedMergeModes)
2728 assert.True(t, s.FailingChecksBlock)
2829 assert.True(t, s.Allows(domain.MergeModeSquash))
2930 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
@@ -39,7 +40,7 @@ func TestNormalizeMergeModes(t *testing.T) {
3940 t.Parallel()
4041 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
4142 require.NoError(t, err)
42- assert.Equal(t, domain.MergeModes(), got)
43+ assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, got)
4344
4445 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
4546 require.NoError(t, err)
@@ -56,7 +57,10 @@ func TestParseMergeMode(t *testing.T) {
5657 m, err := domain.ParseMergeMode("fast-forward")
5758 require.NoError(t, err)
5859 assert.Equal(t, domain.MergeModeFastForward, m)
59- _, err = domain.ParseMergeMode("merge")
60+ m, err = domain.ParseMergeMode("merge")
61+ require.NoError(t, err)
62+ assert.Equal(t, domain.MergeModeMerge, m)
63+ _, err = domain.ParseMergeMode("rebase")
6064 assert.ErrorIs(t, err, domain.ErrInvalid)
6165 }
6266
@@ -283,3 +287,46 @@ func TestReviewScopeOwnerRights(t *testing.T) {
283287 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284288 }
285289 }
290+
291+func TestNewMergeCommit(t *testing.T) {
292+ t.Parallel()
293+ title, err := domain.ParseMRTitle("Add things")
294+ require.NoError(t, err)
295+ mr := domain.MergeRequest{Number: 7, Title: title, Source: "feat/x", Target: "main"}
296+ at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
297+ c := domain.NewMergeCommit(mr, oid(t, 'a'), oid(t, 'b'), oid(t, 'c'), domain.Identity{Name: "ermite", Email: "e@x"}, at)
298+ assert.Equal(t, []domain.ObjectID{oid(t, 'b'), oid(t, 'c')}, c.Parents)
299+ assert.Equal(t, c.Author, c.Committer)
300+ want := "tree " + strings.Repeat("a", 40) + "\nparent " + strings.Repeat("b", 40) + "\nparent " + strings.Repeat("c", 40) + "\n" +
301+ "author ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\ncommitter ermite <e@x> " + strconv.FormatInt(at.Unix(), 10) + " +0000\n\n" +
302+ "Merge branch 'feat/x' into main (!7)\n\nAdd things\n"
303+ assert.Equal(t, want, string(c.Payload()))
304+ assert.Contains(t, string(c.Signed("-----BEGIN-----\nabc\n-----END-----\n")), "gpgsig -----BEGIN-----\n abc\n -----END-----\n")
305+}
306+
307+func TestMergeabilityAllowsAndIsFor(t *testing.T) {
308+ t.Parallel()
309+ for _, tc := range []struct {
310+ state domain.MergeabilityState
311+ ff, squash, mg bool
312+ }{
313+ {domain.MergeabilityUpToDate, true, true, true},
314+ {domain.MergeabilityBehind, false, true, true},
315+ {domain.MergeabilityConflict, false, false, false},
316+ {domain.MergeabilityChecking, false, false, false},
317+ } {
318+ m := domain.Mergeability{State: tc.state}
319+ ok, reason := m.Allows(domain.MergeModeFastForward)
320+ assert.Equal(t, tc.ff, ok, tc.state)
321+ assert.Equal(t, !ok, reason != "")
322+ ok, _ = m.Allows(domain.MergeModeSquash)
323+ assert.Equal(t, tc.squash, ok, tc.state)
324+ ok, _ = m.Allows(domain.MergeModeMerge)
325+ assert.Equal(t, tc.mg, ok, tc.state)
326+ }
327+ m := domain.Mergeability{State: domain.MergeabilityBehind, TargetID: oid(t, 'a'), HeadID: oid(t, 'b')}
328+ assert.True(t, m.IsFor(oid(t, 'a'), oid(t, 'b')))
329+ assert.False(t, m.IsFor(oid(t, 'c'), oid(t, 'b')))
330+ assert.False(t, m.IsFor(oid(t, 'a'), oid(t, 'c')))
331+ assert.False(t, domain.Mergeability{}.IsFor(domain.ObjectID{}, domain.ObjectID{}))
332+}
A internal/domain/mergetree_bound_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strconv"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestMergeTreeResultBounded(t *testing.T) {
16+ var paths []string
17+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
18+ paths = append(paths, strconv.Itoa(i))
19+ }
20+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
21+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
22+ assert.True(t, got.Truncated)
23+
24+ got = domain.MergeTreeResult{ConflictPaths: paths[:domain.MaxConflictPaths]}.Bounded()
25+ assert.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
26+ assert.False(t, got.Truncated)
27+}
28+
29+func TestBoundBehind(t *testing.T) {
30+ n, capped := domain.BoundBehind(domain.MaxBehindCount)
31+ assert.Equal(t, domain.MaxBehindCount, n)
32+ assert.False(t, capped)
33+ n, capped = domain.BoundBehind(domain.BehindLimit)
34+ assert.Equal(t, domain.MaxBehindCount, n)
35+ assert.True(t, capped)
36+}
37+
38+func TestMergeTreeResultBoundedCopiesThePaths(t *testing.T) {
39+ var paths []string
40+ for i := 0; i < domain.MaxConflictPaths+2; i++ {
41+ paths = append(paths, strconv.Itoa(i))
42+ }
43+ got := domain.MergeTreeResult{ConflictPaths: paths}.Bounded()
44+ require.Len(t, got.ConflictPaths, domain.MaxConflictPaths)
45+ assert.Equal(t, len(got.ConflictPaths), cap(got.ConflictPaths))
46+ assert.NotSame(t, &paths[0], &got.ConflictPaths[0], "a new array, not the git output's")
47+ assert.Equal(t, paths[:domain.MaxConflictPaths], got.ConflictPaths)
48+
49+ got = domain.MergeTreeResult{ConflictPaths: paths[:3:10]}.Bounded()
50+ assert.Equal(t, 3, cap(got.ConflictPaths))
51+ assert.False(t, got.Truncated)
52+ assert.Nil(t, domain.MergeTreeResult{}.Bounded().ConflictPaths)
53+}
M internal/ports/ci.go +3 -0
@@ -93,6 +93,9 @@ type CIRefTipStore interface {
9393 GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error)
9494 // DeleteRefTip removes the record of a ref; a missing one is not an error.
9595 DeleteRefTip(ctx context.Context, repo domain.RepoName, ref string) error
96+ // DeleteRefTipIf removes the record of a ref only when it still names
97+ // sha; a missing or different one is left alone and is not an error.
98+ DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref, sha string) error
9699 }
97100
98101 // CIWorkflowStore keeps, per repository, the workflow files that are disabled
M internal/ports/dto.go +8 -2
@@ -198,6 +198,9 @@ type MergeRequestPage struct {
198198 MergeRequests []domain.MergeRequest
199199 // NextPageToken is empty on the last page.
200200 NextPageToken string
201+ // Mergeability holds, by number, the mergeability of the open requests of
202+ // the page (nil when the server does not compute it).
203+ Mergeability map[int64]domain.Mergeability
201204 }
202205
203206 // ListIssuesParams selects a page of issues from an IssueStore.
@@ -262,6 +265,8 @@ type MergeRequestDetail struct {
262265 Comments []domain.Comment
263266 // Verdicts are the latest verdict per author in note ID order.
264267 Verdicts []domain.AuthorVerdict
268+ // Mergeability is zero unless the request is open.
269+ Mergeability domain.Mergeability
265270 // LastReadID is the head recorded by MarkRead; zero when none.
266271 LastReadID domain.ObjectID
267272 // LastReadLost tells that since-last-read was asked for, a head was
@@ -319,8 +324,9 @@ type MergeParams struct {
319324 // MergeResult is a merged request and what happened to its source branch.
320325 type MergeResult struct {
321326 domain.MergeRequest
322- // SourceDeleted is true when DeleteSource was asked and the branch is
323- // gone.
327+ // SourceDeleted is true when DeleteSource was asked: the deletion is
328+ // requested, and done asynchronously after the merge returned (it is
329+ // skipped if the branch moved meanwhile).
324330 SourceDeleted bool
325331 }
326332
M internal/ports/mocks/CIRefTipStore.go +69 -0
@@ -110,6 +110,75 @@ func (_c *CIRefTipStore_DeleteRefTip_Call) RunAndReturn(run func(ctx context.Con
110110 return _c
111111 }
112112
113+// DeleteRefTipIf provides a mock function for the type CIRefTipStore
114+func (_mock *CIRefTipStore) DeleteRefTipIf(ctx context.Context, repo domain.RepoName, ref string, sha string) error {
115+ ret := _mock.Called(ctx, repo, ref, sha)
116+
117+ if len(ret) == 0 {
118+ panic("no return value specified for DeleteRefTipIf")
119+ }
120+
121+ var r0 error
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, string) error); ok {
123+ r0 = returnFunc(ctx, repo, ref, sha)
124+ } else {
125+ r0 = ret.Error(0)
126+ }
127+ return r0
128+}
129+
130+// CIRefTipStore_DeleteRefTipIf_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRefTipIf'
131+type CIRefTipStore_DeleteRefTipIf_Call struct {
132+ *mock.Call
133+}
134+
135+// DeleteRefTipIf is a helper method to define mock.On call
136+// - ctx context.Context
137+// - repo domain.RepoName
138+// - ref string
139+// - sha string
140+func (_e *CIRefTipStore_Expecter) DeleteRefTipIf(ctx any, repo any, ref any, sha any) *CIRefTipStore_DeleteRefTipIf_Call {
141+ return &CIRefTipStore_DeleteRefTipIf_Call{Call: _e.mock.On("DeleteRefTipIf", ctx, repo, ref, sha)}
142+}
143+
144+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Run(run func(ctx context.Context, repo domain.RepoName, ref string, sha string)) *CIRefTipStore_DeleteRefTipIf_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ var arg1 domain.RepoName
151+ if args[1] != nil {
152+ arg1 = args[1].(domain.RepoName)
153+ }
154+ var arg2 string
155+ if args[2] != nil {
156+ arg2 = args[2].(string)
157+ }
158+ var arg3 string
159+ if args[3] != nil {
160+ arg3 = args[3].(string)
161+ }
162+ run(
163+ arg0,
164+ arg1,
165+ arg2,
166+ arg3,
167+ )
168+ })
169+ return _c
170+}
171+
172+func (_c *CIRefTipStore_DeleteRefTipIf_Call) Return(err error) *CIRefTipStore_DeleteRefTipIf_Call {
173+ _c.Call.Return(err)
174+ return _c
175+}
176+
177+func (_c *CIRefTipStore_DeleteRefTipIf_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ref string, sha string) error) *CIRefTipStore_DeleteRefTipIf_Call {
178+ _c.Call.Return(run)
179+ return _c
180+}
181+
113182 // GetRefTip provides a mock function for the type CIRefTipStore
114183 func (_mock *CIRefTipStore) GetRefTip(ctx context.Context, repo domain.RepoName, ref string) (domain.CIRefTip, error) {
115184 ret := _mock.Called(ctx, repo, ref)
M internal/ports/mocks/MergeGit.go +162 -0
@@ -191,6 +191,90 @@ func (_c *MergeGit_CommitTree_Call) RunAndReturn(run func(ctx context.Context, r
191191 return _c
192192 }
193193
194+// CountBehind provides a mock function for the type MergeGit
195+func (_mock *MergeGit) CountBehind(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error) {
196+ ret := _mock.Called(ctx, repo, head, target, limit)
197+
198+ if len(ret) == 0 {
199+ panic("no return value specified for CountBehind")
200+ }
201+
202+ var r0 int
203+ var r1 error
204+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) (int, error)); ok {
205+ return returnFunc(ctx, repo, head, target, limit)
206+ }
207+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) int); ok {
208+ r0 = returnFunc(ctx, repo, head, target, limit)
209+ } else {
210+ r0 = ret.Get(0).(int)
211+ }
212+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
213+ r1 = returnFunc(ctx, repo, head, target, limit)
214+ } else {
215+ r1 = ret.Error(1)
216+ }
217+ return r0, r1
218+}
219+
220+// MergeGit_CountBehind_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountBehind'
221+type MergeGit_CountBehind_Call struct {
222+ *mock.Call
223+}
224+
225+// CountBehind is a helper method to define mock.On call
226+// - ctx context.Context
227+// - repo domain.RepoName
228+// - head domain.ObjectID
229+// - target domain.ObjectID
230+// - limit int
231+func (_e *MergeGit_Expecter) CountBehind(ctx any, repo any, head any, target any, limit any) *MergeGit_CountBehind_Call {
232+ return &MergeGit_CountBehind_Call{Call: _e.mock.On("CountBehind", ctx, repo, head, target, limit)}
233+}
234+
235+func (_c *MergeGit_CountBehind_Call) Run(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int)) *MergeGit_CountBehind_Call {
236+ _c.Call.Run(func(args mock.Arguments) {
237+ var arg0 context.Context
238+ if args[0] != nil {
239+ arg0 = args[0].(context.Context)
240+ }
241+ var arg1 domain.RepoName
242+ if args[1] != nil {
243+ arg1 = args[1].(domain.RepoName)
244+ }
245+ var arg2 domain.ObjectID
246+ if args[2] != nil {
247+ arg2 = args[2].(domain.ObjectID)
248+ }
249+ var arg3 domain.ObjectID
250+ if args[3] != nil {
251+ arg3 = args[3].(domain.ObjectID)
252+ }
253+ var arg4 int
254+ if args[4] != nil {
255+ arg4 = args[4].(int)
256+ }
257+ run(
258+ arg0,
259+ arg1,
260+ arg2,
261+ arg3,
262+ arg4,
263+ )
264+ })
265+ return _c
266+}
267+
268+func (_c *MergeGit_CountBehind_Call) Return(n int, err error) *MergeGit_CountBehind_Call {
269+ _c.Call.Return(n, err)
270+ return _c
271+}
272+
273+func (_c *MergeGit_CountBehind_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, head domain.ObjectID, target domain.ObjectID, limit int) (int, error)) *MergeGit_CountBehind_Call {
274+ _c.Call.Return(run)
275+ return _c
276+}
277+
194278 // DeleteBranch provides a mock function for the type MergeGit
195279 func (_mock *MergeGit) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
196280 ret := _mock.Called(ctx, repo, branch, old)
@@ -670,6 +754,84 @@ func (_c *MergeGit_MergeBase_Call) RunAndReturn(run func(ctx context.Context, re
670754 return _c
671755 }
672756
757+// MergeTree provides a mock function for the type MergeGit
758+func (_mock *MergeGit) MergeTree(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error) {
759+ ret := _mock.Called(ctx, repo, base, head)
760+
761+ if len(ret) == 0 {
762+ panic("no return value specified for MergeTree")
763+ }
764+
765+ var r0 domain.MergeTreeResult
766+ var r1 error
767+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (domain.MergeTreeResult, error)); ok {
768+ return returnFunc(ctx, repo, base, head)
769+ }
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) domain.MergeTreeResult); ok {
771+ r0 = returnFunc(ctx, repo, base, head)
772+ } else {
773+ r0 = ret.Get(0).(domain.MergeTreeResult)
774+ }
775+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok {
776+ r1 = returnFunc(ctx, repo, base, head)
777+ } else {
778+ r1 = ret.Error(1)
779+ }
780+ return r0, r1
781+}
782+
783+// MergeGit_MergeTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MergeTree'
784+type MergeGit_MergeTree_Call struct {
785+ *mock.Call
786+}
787+
788+// MergeTree is a helper method to define mock.On call
789+// - ctx context.Context
790+// - repo domain.RepoName
791+// - base domain.ObjectID
792+// - head domain.ObjectID
793+func (_e *MergeGit_Expecter) MergeTree(ctx any, repo any, base any, head any) *MergeGit_MergeTree_Call {
794+ return &MergeGit_MergeTree_Call{Call: _e.mock.On("MergeTree", ctx, repo, base, head)}
795+}
796+
797+func (_c *MergeGit_MergeTree_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID)) *MergeGit_MergeTree_Call {
798+ _c.Call.Run(func(args mock.Arguments) {
799+ var arg0 context.Context
800+ if args[0] != nil {
801+ arg0 = args[0].(context.Context)
802+ }
803+ var arg1 domain.RepoName
804+ if args[1] != nil {
805+ arg1 = args[1].(domain.RepoName)
806+ }
807+ var arg2 domain.ObjectID
808+ if args[2] != nil {
809+ arg2 = args[2].(domain.ObjectID)
810+ }
811+ var arg3 domain.ObjectID
812+ if args[3] != nil {
813+ arg3 = args[3].(domain.ObjectID)
814+ }
815+ run(
816+ arg0,
817+ arg1,
818+ arg2,
819+ arg3,
820+ )
821+ })
822+ return _c
823+}
824+
825+func (_c *MergeGit_MergeTree_Call) Return(mergeTreeResult domain.MergeTreeResult, err error) *MergeGit_MergeTree_Call {
826+ _c.Call.Return(mergeTreeResult, err)
827+ return _c
828+}
829+
830+func (_c *MergeGit_MergeTree_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID) (domain.MergeTreeResult, error)) *MergeGit_MergeTree_Call {
831+ _c.Call.Return(run)
832+ return _c
833+}
834+
673835 // UpdateBranch provides a mock function for the type MergeGit
674836 func (_mock *MergeGit) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID, new domain.ObjectID) error {
675837 ret := _mock.Called(ctx, repo, branch, old, new)
M internal/ports/mocks/MergeRequestService.go +78 -0
@@ -942,6 +942,84 @@ func (_c *MergeRequestService_Merge_Call) RunAndReturn(run func(ctx context.Cont
942942 return _c
943943 }
944944
945+// Mergeability provides a mock function for the type MergeRequestService
946+func (_mock *MergeRequestService) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
947+ ret := _mock.Called(ctx, p, name, number)
948+
949+ if len(ret) == 0 {
950+ panic("no return value specified for Mergeability")
951+ }
952+
953+ var r0 domain.Mergeability
954+ var r1 error
955+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Mergeability, error)); ok {
956+ return returnFunc(ctx, p, name, number)
957+ }
958+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.Mergeability); ok {
959+ r0 = returnFunc(ctx, p, name, number)
960+ } else {
961+ r0 = ret.Get(0).(domain.Mergeability)
962+ }
963+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
964+ r1 = returnFunc(ctx, p, name, number)
965+ } else {
966+ r1 = ret.Error(1)
967+ }
968+ return r0, r1
969+}
970+
971+// MergeRequestService_Mergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mergeability'
972+type MergeRequestService_Mergeability_Call struct {
973+ *mock.Call
974+}
975+
976+// Mergeability is a helper method to define mock.On call
977+// - ctx context.Context
978+// - p domain.Principal
979+// - name domain.RepoName
980+// - number int64
981+func (_e *MergeRequestService_Expecter) Mergeability(ctx any, p any, name any, number any) *MergeRequestService_Mergeability_Call {
982+ return &MergeRequestService_Mergeability_Call{Call: _e.mock.On("Mergeability", ctx, p, name, number)}
983+}
984+
985+func (_c *MergeRequestService_Mergeability_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_Mergeability_Call {
986+ _c.Call.Run(func(args mock.Arguments) {
987+ var arg0 context.Context
988+ if args[0] != nil {
989+ arg0 = args[0].(context.Context)
990+ }
991+ var arg1 domain.Principal
992+ if args[1] != nil {
993+ arg1 = args[1].(domain.Principal)
994+ }
995+ var arg2 domain.RepoName
996+ if args[2] != nil {
997+ arg2 = args[2].(domain.RepoName)
998+ }
999+ var arg3 int64
1000+ if args[3] != nil {
1001+ arg3 = args[3].(int64)
1002+ }
1003+ run(
1004+ arg0,
1005+ arg1,
1006+ arg2,
1007+ arg3,
1008+ )
1009+ })
1010+ return _c
1011+}
1012+
1013+func (_c *MergeRequestService_Mergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestService_Mergeability_Call {
1014+ _c.Call.Return(mergeability, err)
1015+ return _c
1016+}
1017+
1018+func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestService_Mergeability_Call {
1019+ _c.Call.Return(run)
1020+ return _c
1021+}
1022+
9451023 // ResolveComment provides a mock function for the type MergeRequestService
9461024 func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) {
9471025 ret := _mock.Called(ctx, p, name, number, commentID, resolved)
M internal/ports/mocks/MergeRequestStore.go +192 -0
@@ -179,6 +179,69 @@ func (_c *MergeRequestStore_Create_Call) RunAndReturn(run func(ctx context.Conte
179179 return _c
180180 }
181181
182+// DeleteMergeability provides a mock function for the type MergeRequestStore
183+func (_mock *MergeRequestStore) DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error {
184+ ret := _mock.Called(ctx, repo, number)
185+
186+ if len(ret) == 0 {
187+ panic("no return value specified for DeleteMergeability")
188+ }
189+
190+ var r0 error
191+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) error); ok {
192+ r0 = returnFunc(ctx, repo, number)
193+ } else {
194+ r0 = ret.Error(0)
195+ }
196+ return r0
197+}
198+
199+// MergeRequestStore_DeleteMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMergeability'
200+type MergeRequestStore_DeleteMergeability_Call struct {
201+ *mock.Call
202+}
203+
204+// DeleteMergeability is a helper method to define mock.On call
205+// - ctx context.Context
206+// - repo domain.RepoName
207+// - number int64
208+func (_e *MergeRequestStore_Expecter) DeleteMergeability(ctx any, repo any, number any) *MergeRequestStore_DeleteMergeability_Call {
209+ return &MergeRequestStore_DeleteMergeability_Call{Call: _e.mock.On("DeleteMergeability", ctx, repo, number)}
210+}
211+
212+func (_c *MergeRequestStore_DeleteMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_DeleteMergeability_Call {
213+ _c.Call.Run(func(args mock.Arguments) {
214+ var arg0 context.Context
215+ if args[0] != nil {
216+ arg0 = args[0].(context.Context)
217+ }
218+ var arg1 domain.RepoName
219+ if args[1] != nil {
220+ arg1 = args[1].(domain.RepoName)
221+ }
222+ var arg2 int64
223+ if args[2] != nil {
224+ arg2 = args[2].(int64)
225+ }
226+ run(
227+ arg0,
228+ arg1,
229+ arg2,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MergeRequestStore_DeleteMergeability_Call) Return(err error) *MergeRequestStore_DeleteMergeability_Call {
236+ _c.Call.Return(err)
237+ return _c
238+}
239+
240+func (_c *MergeRequestStore_DeleteMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) error) *MergeRequestStore_DeleteMergeability_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
244+
182245 // Get provides a mock function for the type MergeRequestStore
183246 func (_mock *MergeRequestStore) Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error) {
184247 ret := _mock.Called(ctx, repo, number)
@@ -329,6 +392,78 @@ func (_c *MergeRequestStore_GetComment_Call) RunAndReturn(run func(ctx context.C
329392 return _c
330393 }
331394
395+// GetMergeability provides a mock function for the type MergeRequestStore
396+func (_mock *MergeRequestStore) GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error) {
397+ ret := _mock.Called(ctx, repo, number)
398+
399+ if len(ret) == 0 {
400+ panic("no return value specified for GetMergeability")
401+ }
402+
403+ var r0 domain.Mergeability
404+ var r1 error
405+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.Mergeability, error)); ok {
406+ return returnFunc(ctx, repo, number)
407+ }
408+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.Mergeability); ok {
409+ r0 = returnFunc(ctx, repo, number)
410+ } else {
411+ r0 = ret.Get(0).(domain.Mergeability)
412+ }
413+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
414+ r1 = returnFunc(ctx, repo, number)
415+ } else {
416+ r1 = ret.Error(1)
417+ }
418+ return r0, r1
419+}
420+
421+// MergeRequestStore_GetMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMergeability'
422+type MergeRequestStore_GetMergeability_Call struct {
423+ *mock.Call
424+}
425+
426+// GetMergeability is a helper method to define mock.On call
427+// - ctx context.Context
428+// - repo domain.RepoName
429+// - number int64
430+func (_e *MergeRequestStore_Expecter) GetMergeability(ctx any, repo any, number any) *MergeRequestStore_GetMergeability_Call {
431+ return &MergeRequestStore_GetMergeability_Call{Call: _e.mock.On("GetMergeability", ctx, repo, number)}
432+}
433+
434+func (_c *MergeRequestStore_GetMergeability_Call) Run(run func(ctx context.Context, repo domain.RepoName, number int64)) *MergeRequestStore_GetMergeability_Call {
435+ _c.Call.Run(func(args mock.Arguments) {
436+ var arg0 context.Context
437+ if args[0] != nil {
438+ arg0 = args[0].(context.Context)
439+ }
440+ var arg1 domain.RepoName
441+ if args[1] != nil {
442+ arg1 = args[1].(domain.RepoName)
443+ }
444+ var arg2 int64
445+ if args[2] != nil {
446+ arg2 = args[2].(int64)
447+ }
448+ run(
449+ arg0,
450+ arg1,
451+ arg2,
452+ )
453+ })
454+ return _c
455+}
456+
457+func (_c *MergeRequestStore_GetMergeability_Call) Return(mergeability domain.Mergeability, err error) *MergeRequestStore_GetMergeability_Call {
458+ _c.Call.Return(mergeability, err)
459+ return _c
460+}
461+
462+func (_c *MergeRequestStore_GetMergeability_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)) *MergeRequestStore_GetMergeability_Call {
463+ _c.Call.Return(run)
464+ return _c
465+}
466+
332467 // GetRead provides a mock function for the type MergeRequestStore
333468 func (_mock *MergeRequestStore) GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error) {
334469 ret := _mock.Called(ctx, repo, number)
@@ -845,6 +980,63 @@ func (_c *MergeRequestStore_OpenByTarget_Call) RunAndReturn(run func(ctx context
845980 return _c
846981 }
847982
983+// PutMergeability provides a mock function for the type MergeRequestStore
984+func (_mock *MergeRequestStore) PutMergeability(ctx context.Context, m domain.Mergeability) error {
985+ ret := _mock.Called(ctx, m)
986+
987+ if len(ret) == 0 {
988+ panic("no return value specified for PutMergeability")
989+ }
990+
991+ var r0 error
992+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Mergeability) error); ok {
993+ r0 = returnFunc(ctx, m)
994+ } else {
995+ r0 = ret.Error(0)
996+ }
997+ return r0
998+}
999+
1000+// MergeRequestStore_PutMergeability_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMergeability'
1001+type MergeRequestStore_PutMergeability_Call struct {
1002+ *mock.Call
1003+}
1004+
1005+// PutMergeability is a helper method to define mock.On call
1006+// - ctx context.Context
1007+// - m domain.Mergeability
1008+func (_e *MergeRequestStore_Expecter) PutMergeability(ctx any, m any) *MergeRequestStore_PutMergeability_Call {
1009+ return &MergeRequestStore_PutMergeability_Call{Call: _e.mock.On("PutMergeability", ctx, m)}
1010+}
1011+
1012+func (_c *MergeRequestStore_PutMergeability_Call) Run(run func(ctx context.Context, m domain.Mergeability)) *MergeRequestStore_PutMergeability_Call {
1013+ _c.Call.Run(func(args mock.Arguments) {
1014+ var arg0 context.Context
1015+ if args[0] != nil {
1016+ arg0 = args[0].(context.Context)
1017+ }
1018+ var arg1 domain.Mergeability
1019+ if args[1] != nil {
1020+ arg1 = args[1].(domain.Mergeability)
1021+ }
1022+ run(
1023+ arg0,
1024+ arg1,
1025+ )
1026+ })
1027+ return _c
1028+}
1029+
1030+func (_c *MergeRequestStore_PutMergeability_Call) Return(err error) *MergeRequestStore_PutMergeability_Call {
1031+ _c.Call.Return(err)
1032+ return _c
1033+}
1034+
1035+func (_c *MergeRequestStore_PutMergeability_Call) RunAndReturn(run func(ctx context.Context, m domain.Mergeability) error) *MergeRequestStore_PutMergeability_Call {
1036+ _c.Call.Return(run)
1037+ return _c
1038+}
1039+
8481040 // SaveComment provides a mock function for the type MergeRequestStore
8491041 func (_mock *MergeRequestStore) SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error {
8501042 ret := _mock.Called(ctx, mr, c)
A internal/ports/mocks/MergeabilityReader.go +111 -0
@@ -0,0 +1,111 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityReader creates a new instance of MergeabilityReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityReader is an autogenerated mock type for the MergeabilityReader type
38+type MergeabilityReader struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityReader) EXPECT() *MergeabilityReader_Expecter {
47+ return &MergeabilityReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// Read provides a mock function for the type MergeabilityReader
51+func (_mock *MergeabilityReader) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
52+ ret := _mock.Called(ctx, mr, targetTip)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Read")
56+ }
57+
58+ var r0 domain.Mergeability
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MergeRequest, domain.ObjectID) domain.Mergeability); ok {
60+ r0 = returnFunc(ctx, mr, targetTip)
61+ } else {
62+ r0 = ret.Get(0).(domain.Mergeability)
63+ }
64+ return r0
65+}
66+
67+// MergeabilityReader_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
68+type MergeabilityReader_Read_Call struct {
69+ *mock.Call
70+}
71+
72+// Read is a helper method to define mock.On call
73+// - ctx context.Context
74+// - mr domain.MergeRequest
75+// - targetTip domain.ObjectID
76+func (_e *MergeabilityReader_Expecter) Read(ctx any, mr any, targetTip any) *MergeabilityReader_Read_Call {
77+ return &MergeabilityReader_Read_Call{Call: _e.mock.On("Read", ctx, mr, targetTip)}
78+}
79+
80+func (_c *MergeabilityReader_Read_Call) Run(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID)) *MergeabilityReader_Read_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ var arg1 domain.MergeRequest
87+ if args[1] != nil {
88+ arg1 = args[1].(domain.MergeRequest)
89+ }
90+ var arg2 domain.ObjectID
91+ if args[2] != nil {
92+ arg2 = args[2].(domain.ObjectID)
93+ }
94+ run(
95+ arg0,
96+ arg1,
97+ arg2,
98+ )
99+ })
100+ return _c
101+}
102+
103+func (_c *MergeabilityReader_Read_Call) Return(mergeability domain.Mergeability) *MergeabilityReader_Read_Call {
104+ _c.Call.Return(mergeability)
105+ return _c
106+}
107+
108+func (_c *MergeabilityReader_Read_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability) *MergeabilityReader_Read_Call {
109+ _c.Call.Return(run)
110+ return _c
111+}
A internal/ports/mocks/MergeabilityTrigger.go +146 -0
@@ -0,0 +1,146 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMergeabilityTrigger creates a new instance of MergeabilityTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMergeabilityTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MergeabilityTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MergeabilityTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MergeabilityTrigger is an autogenerated mock type for the MergeabilityTrigger type
38+type MergeabilityTrigger struct {
39+ mock.Mock
40+}
41+
42+type MergeabilityTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MergeabilityTrigger) EXPECT() *MergeabilityTrigger_Expecter {
47+ return &MergeabilityTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnBranchesMoved provides a mock function for the type MergeabilityTrigger
51+func (_mock *MergeabilityTrigger) OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MergeabilityTrigger_OnBranchesMoved_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnBranchesMoved'
57+type MergeabilityTrigger_OnBranchesMoved_Call struct {
58+ *mock.Call
59+}
60+
61+// OnBranchesMoved is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MergeabilityTrigger_Expecter) OnBranchesMoved(ctx any, repo any, updates any) *MergeabilityTrigger_OnBranchesMoved_Call {
66+ return &MergeabilityTrigger_OnBranchesMoved_Call{Call: _e.mock.On("OnBranchesMoved", ctx, repo, updates)}
67+}
68+
69+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) Return() *MergeabilityTrigger_OnBranchesMoved_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MergeabilityTrigger_OnBranchesMoved_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MergeabilityTrigger_OnBranchesMoved_Call {
98+ _c.Run(run)
99+ return _c
100+}
101+
102+// OnOpened provides a mock function for the type MergeabilityTrigger
103+func (_mock *MergeabilityTrigger) OnOpened(ctx context.Context, mr domain.MergeRequest) {
104+ _mock.Called(ctx, mr)
105+ return
106+}
107+
108+// MergeabilityTrigger_OnOpened_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOpened'
109+type MergeabilityTrigger_OnOpened_Call struct {
110+ *mock.Call
111+}
112+
113+// OnOpened is a helper method to define mock.On call
114+// - ctx context.Context
115+// - mr domain.MergeRequest
116+func (_e *MergeabilityTrigger_Expecter) OnOpened(ctx any, mr any) *MergeabilityTrigger_OnOpened_Call {
117+ return &MergeabilityTrigger_OnOpened_Call{Call: _e.mock.On("OnOpened", ctx, mr)}
118+}
119+
120+func (_c *MergeabilityTrigger_OnOpened_Call) Run(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
121+ _c.Call.Run(func(args mock.Arguments) {
122+ var arg0 context.Context
123+ if args[0] != nil {
124+ arg0 = args[0].(context.Context)
125+ }
126+ var arg1 domain.MergeRequest
127+ if args[1] != nil {
128+ arg1 = args[1].(domain.MergeRequest)
129+ }
130+ run(
131+ arg0,
132+ arg1,
133+ )
134+ })
135+ return _c
136+}
137+
138+func (_c *MergeabilityTrigger_OnOpened_Call) Return() *MergeabilityTrigger_OnOpened_Call {
139+ _c.Call.Return()
140+ return _c
141+}
142+
143+func (_c *MergeabilityTrigger_OnOpened_Call) RunAndReturn(run func(ctx context.Context, mr domain.MergeRequest)) *MergeabilityTrigger_OnOpened_Call {
144+ _c.Run(run)
145+ return _c
146+}
M internal/ports/ports.go +37 -0
@@ -377,6 +377,17 @@ type MergeRequestStore interface {
377377 // domain.ErrConflict when concurrent writes to the repository keep it
378378 // from landing.
379379 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
380+ // GetMergeability returns the stored mergeability of an open merge request,
381+ // or domain.ErrNotFound when there is none.
382+ GetMergeability(ctx context.Context, repo domain.RepoName, number int64) (domain.Mergeability, error)
383+ // PutMergeability stores the mergeability. A Version of 0 requires that
384+ // none is stored, otherwise the stored one must be unchanged since it was
385+ // read (domain.ErrConflict). It returns domain.ErrNotFound when the merge
386+ // request is not stored or not open, so that a late write never resurrects
387+ // a record.
388+ PutMergeability(ctx context.Context, m domain.Mergeability) error
389+ // DeleteMergeability removes the stored mergeability; no error when absent.
390+ DeleteMergeability(ctx context.Context, repo domain.RepoName, number int64) error
380391 }
381392
382393 // MergeGit is what merge requests need from the Git repositories beyond
@@ -412,6 +423,32 @@ type MergeGit interface {
412423 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
413424 // returns domain.ErrConflict when the branch moved or is gone.
414425 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
426+ // MergeTree merges head into base without creating a commit. When the
427+ // merge is not clean (or the commits share no history) Clean is false and
428+ // ConflictPaths lists every conflicted path, unbounded: the caller bounds
429+ // them (MergeTreeResult.Bounded).
430+ MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error)
431+ // CountBehind counts the commits of target that head lacks, stopping at
432+ // limit: the raw count, at most limit. The caller interprets it
433+ // (domain.BoundBehind).
434+ CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (n int, err error)
435+}
436+
437+// MergeabilityTrigger queues mergeability checks. It only queues the work and
438+// returns.
439+type MergeabilityTrigger interface {
440+ // OnBranchesMoved queues the open merge requests whose source or target
441+ // branch was moved or deleted by updates.
442+ OnBranchesMoved(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)
443+ // OnOpened queues a newly opened merge request.
444+ OnOpened(ctx context.Context, mr domain.MergeRequest)
445+}
446+
447+// MergeabilityReader reads the mergeability of open merge requests.
448+type MergeabilityReader interface {
449+ // Read returns the stored mergeability for mr and targetTip, or a
450+ // "checking" one (and queues a check) when there is none or it is stale.
451+ Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability
415452 }
416453
417454 // CommitSigner signs the commits ermite creates (ADR 0022). The private key
M internal/ports/services.go +4 -0
@@ -124,6 +124,10 @@ type MergeRequestService interface {
124124 // CountOpen is the number of open merge requests of a repository the
125125 // principal can read. It reads no Git: the tab of every page shows it.
126126 CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
127+ // Mergeability is the mergeability of an open merge request, "checking"
128+ // until the server has a result for the current tips; zero for a request
129+ // that is not open. It reads no diff: the live pages call it on each change.
130+ Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error)
127131 }
128132
129133 // IssueService is the issue use cases. *usecase.Issues implements it.
M internal/usecase/ci_unit_test.go +1 -0
@@ -54,6 +54,7 @@ func newMergeUnit(t *testing.T, block bool) *mergeUnit {
5454 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
5555 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
5656 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
5758 return m
5859 }
5960
M internal/usecase/events.go +5 -1
@@ -198,6 +198,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
198198 return repoSnapshot(ev.Visibility, ev.CreatedAt)
199199 case domain.CommentResolved:
200200 return repoSnapshot(ev.Visibility, ev.CreatedAt)
201+ case domain.MergeRequestMergeability:
202+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
201203 case domain.RunUpdated:
202204 return repoSnapshot(ev.Visibility, ev.CreatedAt)
203205 case domain.JobUpdated:
@@ -541,6 +543,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
541543 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
542544 case domain.CommentResolved:
543545 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
546+ case domain.MergeRequestMergeability:
547+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
544548 case domain.RunUpdated:
545549 // Home shows the CI state of each repository.
546550 return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
@@ -645,7 +649,7 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
645649 func isItemEvent(ev domain.Event) bool {
646650 switch ev.(type) {
647651 case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
648- domain.MergeRequestReady, domain.CommentAdded,
652+ domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
649653 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
650654 return true
651655 }
M internal/usecase/events_test.go +13 -0
@@ -997,3 +997,16 @@ func TestNoteMutationEventVisibility(t *testing.T) {
997997 }
998998 })
999999 }
1000+
1001+func TestMergeabilityEventReachesOwnerTopic(t *testing.T) {
1002+ synctest.Test(t, func(t *testing.T) {
1003+ r := newEventRig(t, 0)
1004+ priv := r.repo("priv", domain.VisibilityPrivate)
1005+ ctx, cancel := context.WithCancel(context.Background())
1006+ defer cancel()
1007+ owner := r.watch(ctx, domain.Root(), ports.WatchParams{Topics: []string{"repo:priv"}})
1008+ synctest.Wait()
1009+ r.feed <- domain.MergeRequestMergeability{Repo: priv, Number: 1, Visibility: domain.VisibilityPrivate, CreatedAt: now, At: now}
1010+ assert.Equal(t, priv, owner.next(t).Event.(domain.MergeRequestMergeability).Repo)
1011+ })
1012+}
M internal/usecase/export_test.go +46 -0
@@ -161,3 +161,49 @@ func (s *IssueSubmissions) FillSpent(n int, exp time.Time) {
161161
162162 // MaxSpent is the bound of the spent set.
163163 const MaxSpent = maxSpent
164+
165+// SetAfterMergeGate makes the post-merge goroutine of m run f before anything
166+// else, to place a test between a merge and its side effects.
167+func (m *MergeRequests) SetAfterMergeGate(f func()) { m.afterGate = f }
168+
169+// ScheduledRetries gives the number of retries waiting in the scheduler.
170+func (s *Mergeability) ScheduledRetries() int {
171+ s.retryMu.Lock()
172+ defer s.retryMu.Unlock()
173+ return len(s.heap)
174+}
175+
176+// RetryAttempts gives the number of repositories whose
177+// failed attempts are counted.
178+func (s *Mergeability) RetryAttempts() int {
179+ s.retryMu.Lock()
180+ defer s.retryMu.Unlock()
181+ return len(s.retries)
182+}
183+
184+// ReconcileForTest runs a repository pass synchronously without workers.
185+func (s *Mergeability) ReconcileForTest(repo domain.RepoName) bool { return s.reconcile(repo) }
186+
187+// RetryForTest schedules a retry of repo as a failed pass does.
188+func (s *Mergeability) RetryForTest(repo domain.RepoName) { s.retry(repo) }
189+
190+// RetryDoneForTest forgets the retries of repo as a successful pass does.
191+func (s *Mergeability) RetryDoneForTest(repo domain.RepoName) { s.retryDone(repo) }
192+
193+// RetryDueForTest gives the deadline of the retry scheduled for repo.
194+func (s *Mergeability) RetryDueForTest(repo domain.RepoName) (time.Time, bool) {
195+ s.retryMu.Lock()
196+ defer s.retryMu.Unlock()
197+ for _, it := range s.heap {
198+ if it.key == repo {
199+ return it.due, true
200+ }
201+ }
202+ return time.Time{}, false
203+}
204+
205+// PopDueForTest is the scheduler step taken when the timer armed for the
206+// retry (repo, due) fires. It gives the repositories it released.
207+func (s *Mergeability) PopDueForTest(repo domain.RepoName, due time.Time) []domain.RepoName {
208+ return s.popDue(retryItem{key: repo, due: due})
209+}
A internal/usecase/mergeability.go +467 -0
@@ -0,0 +1,467 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "slices"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+const (
18+ defaultMergeabilityWorkers = 2
19+ mergeabilityRetries = 5
20+ mergeabilityRetryInitial = time.Second
21+ mergeabilityRetryMax = time.Minute
22+)
23+
24+// mergeBackoff is the delay before retry number attempt (1-based).
25+func mergeBackoff(attempt int) time.Duration {
26+ d := mergeabilityRetryInitial
27+ for i := 1; i < attempt; i++ {
28+ d *= 2
29+ if d >= mergeabilityRetryMax {
30+ return mergeabilityRetryMax
31+ }
32+ }
33+ return d
34+}
35+
36+// MergeabilityConfig holds the dependencies of the repository reconciler.
37+type MergeabilityConfig struct {
38+ Store ports.MergeRequestStore
39+ Meta ports.RepoMetaStore
40+ Git ports.MergeGit
41+ Bus ports.EventBus
42+ Clock ports.Clock
43+ Workers int // defaults to 2
44+ // After is injectable to drive backoff without wall-clock waits in tests.
45+ After func(time.Duration) <-chan time.Time
46+}
47+
48+// Mergeability reconciles derived records, with one writer per repository.
49+// Its work and retry state are bounded by the number of repositories.
50+type Mergeability struct {
51+ cfg MergeabilityConfig
52+ ctx context.Context
53+ cancel context.CancelFunc
54+ wg sync.WaitGroup
55+ mu sync.Mutex
56+ dirty map[domain.RepoName]bool
57+ order []domain.RepoName
58+ running, again map[domain.RepoName]bool
59+ wake chan struct{}
60+ retryMu sync.Mutex
61+ retries map[domain.RepoName]int
62+ scheduled map[domain.RepoName]bool
63+ heap []retryItem
64+ retryKick chan struct{}
65+ closed bool
66+}
67+
68+// MergeabilityHook is the unchanged trigger and reader contract.
69+type MergeabilityHook interface {
70+ ports.MergeabilityTrigger
71+ ports.MergeabilityReader
72+}
73+
74+var _ MergeabilityHook = (*Mergeability)(nil)
75+
76+// NewMergeability builds a reconciler whose lifetime is controlled by Close.
77+func NewMergeability(ctx context.Context, cfg MergeabilityConfig) *Mergeability {
78+ if cfg.Workers <= 0 {
79+ cfg.Workers = defaultMergeabilityWorkers
80+ }
81+ if cfg.After == nil {
82+ cfg.After = time.After
83+ }
84+ ctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
85+ return &Mergeability{cfg: cfg, ctx: ctx, cancel: cancel,
86+ dirty: map[domain.RepoName]bool{}, running: map[domain.RepoName]bool{}, again: map[domain.RepoName]bool{}, wake: make(chan struct{}, 1),
87+ retries: map[domain.RepoName]int{}, scheduled: map[domain.RepoName]bool{}, retryKick: make(chan struct{}, 1)}
88+}
89+
90+// Start starts workers and discovers repositories, retrying failed discovery.
91+func (s *Mergeability) Start() {
92+ s.wg.Add(2 + s.cfg.Workers)
93+ go s.scheduler()
94+ go func() {
95+ defer s.wg.Done()
96+ for attempt := 0; attempt <= mergeabilityRetries; attempt++ {
97+ repos, err := s.cfg.Meta.List(s.ctx)
98+ if err != nil && s.ctx.Err() == nil {
99+ ctxLog(s.ctx).Error().Err(err).Int("attempt", attempt+1).Msg("mergeability: list the repositories at startup")
100+ }
101+ if err == nil {
102+ for _, repo := range repos {
103+ s.markDirty(repo.Name)
104+ }
105+ return
106+ }
107+ if attempt == mergeabilityRetries {
108+ return
109+ }
110+ select {
111+ case <-s.ctx.Done():
112+ return
113+ case <-s.cfg.After(mergeBackoff(attempt + 1)):
114+ }
115+ }
116+ }()
117+ for range s.cfg.Workers {
118+ go s.loop()
119+ }
120+}
121+
122+// Close cancels workers and pending retry waits, then waits for them.
123+func (s *Mergeability) Close(_ context.Context) {
124+ s.retryMu.Lock()
125+ s.closed = true
126+ s.retryMu.Unlock()
127+ s.cancel()
128+ s.wg.Wait()
129+}
130+
131+func (s *Mergeability) signal() {
132+ select {
133+ case s.wake <- struct{}{}:
134+ default:
135+ }
136+}
137+
138+func (s *Mergeability) markDirty(repo domain.RepoName) {
139+ s.mu.Lock()
140+ if s.running[repo] {
141+ s.again[repo] = true
142+ } else if !s.dirty[repo] {
143+ s.dirty[repo] = true
144+ s.order = append(s.order, repo)
145+ }
146+ s.mu.Unlock()
147+ s.signal()
148+}
149+
150+func (s *Mergeability) next() (domain.RepoName, bool) {
151+ s.mu.Lock()
152+ defer s.mu.Unlock()
153+ if len(s.order) == 0 {
154+ return domain.RepoName{}, false
155+ }
156+ repo := s.order[0]
157+ s.order = s.order[1:]
158+ delete(s.dirty, repo)
159+ s.running[repo] = true
160+ if len(s.order) > 0 {
161+ s.signal()
162+ }
163+ return repo, true
164+}
165+
166+func (s *Mergeability) loop() {
167+ defer s.wg.Done()
168+ for s.ctx.Err() == nil {
169+ repo, found := s.next()
170+ if !found {
171+ select {
172+ case <-s.ctx.Done():
173+ return
174+ case <-s.wake:
175+ }
176+ continue
177+ }
178+ if s.reconcile(repo) {
179+ s.retryDone(repo)
180+ } else {
181+ s.retry(repo)
182+ }
183+ s.mu.Lock()
184+ delete(s.running, repo)
185+ if s.again[repo] {
186+ delete(s.again, repo)
187+ s.dirty[repo] = true
188+ s.order = append(s.order, repo)
189+ s.signal()
190+ }
191+ s.mu.Unlock()
192+ }
193+}
194+
195+// OnOpened implements ports.MergeabilityTrigger.
196+func (s *Mergeability) OnOpened(_ context.Context, mr domain.MergeRequest) { s.markDirty(mr.Repo) }
197+
198+// OnBranchesMoved dirties the repository for any branch update.
199+func (s *Mergeability) OnBranchesMoved(_ context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
200+ for _, update := range updates {
201+ if strings.HasPrefix(update.Ref, "refs/heads/") {
202+ s.markDirty(repo)
203+ return
204+ }
205+ }
206+}
207+
208+// Read returns a current record, or checking and requests reconciliation.
209+func (s *Mergeability) Read(ctx context.Context, mr domain.MergeRequest, targetTip domain.ObjectID) domain.Mergeability {
210+ if !mr.HeadID.IsZero() && !targetTip.IsZero() {
211+ rec, err := s.cfg.Store.GetMergeability(ctx, mr.Repo, mr.Number)
212+ if err == nil && rec.IsFor(targetTip, mr.HeadID) {
213+ return rec
214+ }
215+ }
216+ s.markDirty(mr.Repo)
217+ return domain.Mergeability{Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityChecking}
218+}
219+
220+// reconcile settles each open request for the tips read in this pass. A push
221+// during computation requests another pass; old-tip records are harmless.
222+func (s *Mergeability) reconcile(repo domain.RepoName) bool {
223+ ctx := s.ctx
224+ meta, err := s.cfg.Meta.Get(ctx, repo)
225+ if errors.Is(err, domain.ErrNotFound) {
226+ return true
227+ }
228+ if err != nil {
229+ s.logFailure(ctx, err, "get the repository", repo, 0)
230+ return false
231+ }
232+ mrs, err := s.cfg.Store.ListOpen(ctx, repo)
233+ if err != nil {
234+ s.logFailure(ctx, err, "list the open merge requests", repo, 0)
235+ return false
236+ }
237+ ok := true
238+ for _, mr := range mrs {
239+ if ctx.Err() != nil {
240+ return true
241+ }
242+ head, tip, found, transient := s.tips(ctx, mr)
243+ if !found {
244+ ok = ok && !transient
245+ continue
246+ }
247+ prev, err := s.cfg.Store.GetMergeability(ctx, repo, mr.Number)
248+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
249+ s.logFailure(ctx, err, "get the mergeability", repo, mr.Number)
250+ ok = false
251+ continue
252+ }
253+ if prev.IsFor(tip, head) {
254+ continue
255+ }
256+ res, err := s.compute(ctx, repo, mr.Number, tip, head)
257+ if err != nil {
258+ s.logFailure(ctx, err, "compute the mergeability", repo, mr.Number)
259+ ok = false
260+ continue
261+ }
262+ res.Version = prev.Version
263+ err = s.cfg.Store.PutMergeability(ctx, res)
264+ if errors.Is(err, domain.ErrNotFound) {
265+ continue
266+ }
267+ if err != nil {
268+ if !errors.Is(err, domain.ErrConflict) { // retried by the next pass
269+ s.logFailure(ctx, err, "store the mergeability", repo, mr.Number)
270+ }
271+ ok = false
272+ continue
273+ }
274+ s.cfg.Bus.Publish(ctx, domain.MergeRequestMergeability{
275+ Repo: repo, Number: mr.Number, Mergeability: res, At: res.CheckedAt,
276+ Visibility: meta.Visibility, CreatedAt: meta.CreatedAt,
277+ })
278+ }
279+ return ok
280+}
281+
282+// logFailure logs an unexpected failure; cancellation is expected on Close.
283+func (s *Mergeability) logFailure(ctx context.Context, err error, op string, repo domain.RepoName, number int64) {
284+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, domain.ErrNotFound) {
285+ return
286+ }
287+ e := ctxLog(ctx).Error().Err(err).Str("repo", repo.String())
288+ if number != 0 {
289+ e = e.Int64("number", number)
290+ }
291+ e.Msg("mergeability: " + op)
292+}
293+
294+// retryItem is one scheduled retry.
295+type retryItem struct {
296+ due time.Time
297+ key domain.RepoName
298+}
299+
300+// retry schedules a retry of key after a bounded exponential backoff. It is
301+// deduplicated per key, gives up after mergeabilityRetries attempts and stops
302+// on Close. All retries share one scheduler goroutine.
303+func (s *Mergeability) retry(key domain.RepoName) {
304+ s.retryMu.Lock()
305+ defer s.retryMu.Unlock()
306+ if s.closed || s.scheduled[key] {
307+ return
308+ }
309+ s.retries[key]++
310+ n := s.retries[key]
311+ if n > mergeabilityRetries {
312+ delete(s.retries, key)
313+ return
314+ }
315+ s.scheduled[key] = true
316+ it := retryItem{due: s.cfg.Clock.Now().Add(mergeBackoff(n)), key: key}
317+ i, _ := slices.BinarySearchFunc(s.heap, it, func(a, b retryItem) int {
318+ if a.due.After(b.due) {
319+ return 1
320+ }
321+ return -1 // equal dues keep their insertion order
322+ })
323+ s.heap = slices.Insert(s.heap, i, it)
324+ if i == 0 {
325+ select {
326+ case s.retryKick <- struct{}{}:
327+ default:
328+ }
329+ }
330+}
331+
332+// scheduler is the only goroutine that waits for retries: it sleeps until the
333+// earliest is due and puts it back in the normal queue.
334+func (s *Mergeability) scheduler() {
335+ defer s.wg.Done()
336+ // The timer armed for waited; a kick re-arms it only when the earliest
337+ // retry changed, so a stale kick never starts a second timer.
338+ var (
339+ after <-chan time.Time
340+ waited retryItem
341+ )
342+ for {
343+ s.retryMu.Lock()
344+ var head retryItem
345+ has := len(s.heap) > 0
346+ if has {
347+ head = s.heap[0]
348+ }
349+ s.retryMu.Unlock()
350+ if !has {
351+ after = nil
352+ select {
353+ case <-s.ctx.Done():
354+ return
355+ case <-s.retryKick:
356+ continue
357+ }
358+ }
359+ if after == nil || head.key != waited.key || !head.due.Equal(waited.due) {
360+ after, waited = s.cfg.After(max(0, head.due.Sub(s.cfg.Clock.Now()))), head
361+ }
362+ select {
363+ case <-s.ctx.Done():
364+ return
365+ case <-s.retryKick:
366+ continue // an earlier retry may have arrived
367+ case <-after:
368+ after = nil
369+ }
370+ for _, k := range s.popDue(waited) {
371+ if s.ctx.Err() != nil {
372+ return
373+ }
374+ s.markDirty(k)
375+ }
376+ }
377+}
378+
379+// popDue removes the waited item and the ones due as well. The waited item is
380+// removed regardless of the clock only when both its key and its deadline
381+// match: a replacement retry of the same repository is kept until it is due.
382+func (s *Mergeability) popDue(waited retryItem) []domain.RepoName {
383+ s.retryMu.Lock()
384+ defer s.retryMu.Unlock()
385+ now := s.cfg.Clock.Now()
386+ var out []domain.RepoName
387+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool {
388+ if !(it.key == waited.key && it.due.Equal(waited.due)) && it.due.After(now) {
389+ return false
390+ }
391+ out = append(out, it.key)
392+ delete(s.scheduled, it.key)
393+ return true
394+ })
395+ return out
396+}
397+
398+// retryDone forgets the failures of key after a success.
399+func (s *Mergeability) retryDone(key domain.RepoName) {
400+ s.retryMu.Lock()
401+ delete(s.retries, key)
402+ delete(s.scheduled, key)
403+ s.heap = slices.DeleteFunc(s.heap, func(it retryItem) bool { return it.key == key })
404+ select {
405+ case s.retryKick <- struct{}{}:
406+ default:
407+ }
408+ s.retryMu.Unlock()
409+}
410+
411+// tips reads the heads of the source and the target of mr. ok is false when
412+// the check is over: a branch is gone (the record is deleted) or Git failed,
413+// which is transient.
414+func (s *Mergeability) tips(ctx context.Context, mr domain.MergeRequest) (head, tip domain.ObjectID, ok, transient bool) {
415+ head, herr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Source)
416+ tip, terr := s.cfg.Git.BranchHead(ctx, mr.Repo, mr.Target)
417+ switch {
418+ case errors.Is(herr, domain.ErrNotFound) || errors.Is(terr, domain.ErrNotFound):
419+ if err := s.cfg.Store.DeleteMergeability(ctx, mr.Repo, mr.Number); err != nil {
420+ if ctx.Err() == nil {
421+ ctxLog(ctx).Error().Err(err).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: delete the record of a merge request without a branch")
422+ }
423+ transient = ctx.Err() == nil
424+ }
425+ return domain.ObjectID{}, domain.ObjectID{}, false, transient
426+ case herr != nil || terr != nil:
427+ if ctx.Err() != nil {
428+ return domain.ObjectID{}, domain.ObjectID{}, false, false
429+ }
430+ ctxLog(ctx).Error().Err(errors.Join(herr, terr)).Str("repo", mr.Repo.String()).Int64("number", mr.Number).Msg("mergeability: read the branches")
431+ return domain.ObjectID{}, domain.ObjectID{}, false, true
432+ }
433+ return head, tip, true, false
434+}
435+
436+// compute runs the Git reads that decide the state of head against tip.
437+func (s *Mergeability) compute(ctx context.Context, repo domain.RepoName, number int64, tip, head domain.ObjectID) (domain.Mergeability, error) {
438+ m := domain.Mergeability{Repo: repo, Number: number, TargetID: tip, HeadID: head, CheckedAt: s.cfg.Clock.Now().UTC()}
439+ ancestor := tip == head
440+ if !ancestor {
441+ var err error
442+ if ancestor, err = s.cfg.Git.IsAncestor(ctx, repo, tip, head); err != nil {
443+ return m, err
444+ }
445+ }
446+ if ancestor {
447+ m.State = domain.MergeabilityUpToDate
448+ return m, nil
449+ }
450+ tree, err := s.cfg.Git.MergeTree(ctx, repo, tip, head)
451+ if err != nil {
452+ return m, err
453+ }
454+ if !tree.Clean {
455+ m.State = domain.MergeabilityConflict
456+ tree = tree.Bounded()
457+ m.ConflictPaths, m.ConflictPathsTruncated = tree.ConflictPaths, tree.Truncated
458+ return m, nil
459+ }
460+ m.State = domain.MergeabilityBehind
461+ n, err := s.cfg.Git.CountBehind(ctx, repo, head, tip, domain.BehindLimit)
462+ if err != nil {
463+ return m, err
464+ }
465+ m.Behind, m.BehindCapped = domain.BoundBehind(n)
466+ return m, nil
467+}
A internal/usecase/mergeability_reconcile_test.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMergeabilityBackoffIsBounded(t *testing.T) {
20+ assert.Equal(t, time.Second, mergeBackoff(1))
21+ assert.Equal(t, 2*time.Second, mergeBackoff(2))
22+ assert.Equal(t, 4*time.Second, mergeBackoff(3))
23+ assert.Equal(t, time.Minute, mergeBackoff(20))
24+}
25+
26+func TestMergeabilityReadMarksTheRepoDirty(t *testing.T) {
27+ repo, err := domain.ParseRepoName("proj")
28+ require.NoError(t, err)
29+ oid, err := domain.ParseObjectID("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
30+ require.NoError(t, err)
31+ store := mocks.NewMergeRequestStore(t)
32+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
33+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store})
34+ defer svc.Close(context.Background())
35+ mr := domain.MergeRequest{Repo: repo, Number: 7, HeadID: oid}
36+ assert.Equal(t, domain.MergeabilityChecking, svc.Read(context.Background(), mr, oid).State)
37+ // Another MR in the same repository is the same unit of work.
38+ mr.Number = 8
39+ mr.HeadID = domain.ObjectID{}
40+ svc.Read(context.Background(), mr, oid)
41+ got, found := svc.next()
42+ require.True(t, found)
43+ assert.Equal(t, repo, got)
44+ _, found = svc.next()
45+ assert.False(t, found)
46+}
47+
48+func TestMergeabilityOnePassPerRepoAtATimeAndAgain(t *testing.T) {
49+ repo, err := domain.ParseRepoName("proj")
50+ require.NoError(t, err)
51+ other, err := domain.ParseRepoName("other")
52+ require.NoError(t, err)
53+ meta, store := mocks.NewRepoMetaStore(t), mocks.NewMergeRequestStore(t)
54+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
55+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
56+ meta.EXPECT().Get(mock.Anything, other).Return(domain.Repo{Name: other}, nil)
57+ entered, release, otherPassed := make(chan struct{}, 4), make(chan struct{}), make(chan struct{})
58+ var passes, inflight, peak atomic.Int32
59+ store.EXPECT().ListOpen(mock.Anything, repo).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
60+ n := inflight.Add(1)
61+ if n > peak.Load() {
62+ peak.Store(n)
63+ }
64+ pass := passes.Add(1)
65+ entered <- struct{}{}
66+ if pass == 1 {
67+ <-release
68+ }
69+ inflight.Add(-1)
70+ return nil, nil
71+ })
72+ store.EXPECT().ListOpen(mock.Anything, other).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
73+ close(otherPassed)
74+ return nil, nil
75+ })
76+ svc := NewMergeability(context.Background(), MergeabilityConfig{Store: store, Meta: meta, Workers: 2})
77+ svc.Start()
78+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
79+ <-entered
80+ for range 50 {
81+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
82+ }
83+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: other})
84+ <-otherPassed // the other worker has serviced all work eligible meanwhile
85+ assert.EqualValues(t, 1, passes.Load())
86+ close(release)
87+ <-entered
88+ svc.Close(context.Background())
89+ assert.EqualValues(t, 2, passes.Load(), "all triggers during a pass coalesce into exactly one more pass")
90+ assert.EqualValues(t, 1, peak.Load())
91+}
A internal/usecase/mergeability_test.go +673 -0
@@ -0,0 +1,673 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+ "bnema.dev/ermite/internal/usecase"
22+)
23+
24+func fakeOID(t *testing.T, c string) domain.ObjectID {
25+ t.Helper()
26+ return oidFrom(t, strings.Repeat(c, 40))
27+}
28+
29+// mergeRig runs the mergeability service over the real adapters of mrRig.
30+type mergeRig struct {
31+ *mrRig
32+ svc *usecase.Mergeability
33+ mev <-chan domain.Event
34+ history []domain.MergeRequestMergeability
35+}
36+
37+func newMergeRig(t *testing.T) *mergeRig {
38+ t.Helper()
39+ r := newMRRig(t)
40+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
41+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{}, Workers: 2,
42+ })
43+ r.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
45+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}, Bus: r.bus, Clock: fixedClock{}, Maint: r.sched,
46+ Mergeability: svc,
47+ })
48+ t.Cleanup(func() { svc.Close(context.Background()) })
49+ return &mergeRig{mrRig: r, svc: svc, mev: r.bus.Subscribe(r.ctx)}
50+}
51+
52+func (r *mergeRig) record(n int64) (domain.Mergeability, error) {
53+ return r.store.MergeRequests().GetMergeability(r.ctx, r.repo, n)
54+}
55+
56+func (r *mergeRig) waitState(n int64, want domain.MergeabilityState, target, head domain.ObjectID) domain.Mergeability {
57+ r.t.Helper()
58+ var m domain.Mergeability
59+ deadline := time.After(10 * time.Second)
60+ for {
61+ var err error
62+ m, err = r.record(n)
63+ if err == nil && m.State == want && m.IsFor(target, head) {
64+ break
65+ }
66+ select {
67+ case ev := <-r.mev:
68+ if e, ok := ev.(domain.MergeRequestMergeability); ok {
69+ r.history = append(r.history, e)
70+ }
71+ case <-deadline:
72+ r.t.Fatalf("mergeability of !%d did not become %s", n, want)
73+ }
74+ }
75+
76+ return m
77+}
78+
79+// events returns the mergeability events published so far.
80+func (r *mergeRig) events() []domain.MergeRequestMergeability {
81+ out := r.history
82+ r.history = nil
83+ for {
84+ select {
85+ case ev := <-r.mev:
86+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
87+ out = append(out, m)
88+ }
89+ default:
90+ return out
91+ }
92+ }
93+}
94+
95+// awaitEvents receives mergeability events until n arrived, or fails after a
96+// bound: the workers publish after they stored the record the tests wait for.
97+func (r *mergeRig) awaitEvents(n int) []domain.MergeRequestMergeability {
98+ r.t.Helper()
99+ out := r.history
100+ r.history = nil
101+ deadline := time.After(10 * time.Second)
102+ for len(out) < n {
103+ select {
104+ case ev := <-r.mev:
105+ if m, ok := ev.(domain.MergeRequestMergeability); ok {
106+ out = append(out, m)
107+ }
108+ case <-deadline:
109+ r.t.Fatalf("got %d mergeability events, want %d", len(out), n)
110+ }
111+ }
112+ return out
113+}
114+
115+func TestMergeabilityStatesEndToEnd(t *testing.T) {
116+ r := newMergeRig(t)
117+ r.svc.Start()
118+ main := r.seed()
119+ head := r.feature("feat")
120+ mr := r.open("feat")
121+
122+ // up to date: the target is an ancestor of the head.
123+ m := r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
124+ assert.False(t, m.CheckedAt.IsZero())
125+
126+ // behind: the target moved with a change that merges cleanly.
127+ r.sh("checkout", "-q", "main")
128+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
129+ r.push("main")
130+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
131+ m = r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
132+ assert.Equal(t, 1, m.Behind)
133+ assert.False(t, m.BehindCapped)
134+
135+ // conflict: the target changes a line the head changed.
136+ main3 := r.commit(author, "a.txt", "one\nTWO\nthree\n", "conflict a")
137+ r.push("main")
138+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main2.String(), New: main3.String(), Ref: "refs/heads/main"}})
139+ m = r.waitState(mr.Number, domain.MergeabilityConflict, main3, head)
140+ assert.Equal(t, []string{"a.txt"}, m.ConflictPaths)
141+
142+ got := r.awaitEvents(3)
143+ for i, s := range []domain.MergeabilityState{domain.MergeabilityUpToDate, domain.MergeabilityBehind, domain.MergeabilityConflict} {
144+ assert.Equal(t, s, got[i].Mergeability.State)
145+ assert.Equal(t, mr.Number, got[i].Number)
146+ }
147+}
148+
149+func TestMergeabilityReaderIsLazy(t *testing.T) {
150+ r := newMergeRig(t)
151+ main := r.seed()
152+ head := r.feature("feat")
153+ mr := r.open("feat") // queued, but the workers are not started
154+
155+ d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
156+ require.NoError(t, err)
157+ assert.Equal(t, domain.MergeabilityChecking, d.Mergeability.State)
158+
159+ r.svc.Start()
160+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
161+ d, err = r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{})
162+ require.NoError(t, err)
163+ assert.Equal(t, domain.MergeabilityUpToDate, d.Mergeability.State)
164+
165+ page, err := r.mrs.List(r.ctx, root, r.repo, ports.ListMergeRequestsParams{})
166+ require.NoError(t, err)
167+ assert.Equal(t, domain.MergeabilityUpToDate, page.Mergeability[mr.Number].State)
168+}
169+
170+func TestMergeabilityPublishesEachSettledResultOnce(t *testing.T) {
171+ r := newMergeRig(t)
172+ r.seed()
173+ r.feature("feat")
174+ mr := r.open("feat")
175+ r.svc.ReconcileForTest(r.repo)
176+ require.Len(t, r.events(), 1)
177+
178+ // Same tips: nothing is computed, nothing is published.
179+ r.svc.ReconcileForTest(r.repo)
180+ assert.Empty(t, r.events())
181+
182+ // New tips publish even when the state and counts stay the same.
183+ r.sh("checkout", "-q", "feat")
184+ r.commit(author, "e.txt", "e\n", "more")
185+ r.push("feat")
186+ r.svc.ReconcileForTest(r.repo)
187+ assert.Len(t, r.events(), 1)
188+ m, err := r.record(mr.Number)
189+ require.NoError(t, err)
190+ assert.Equal(t, domain.MergeabilityUpToDate, m.State)
191+}
192+
193+func TestMergeabilityMergeReevaluatesOtherRequests(t *testing.T) {
194+ r := newMergeRig(t)
195+ r.svc.Start()
196+ main := r.seed()
197+ r.sh("checkout", "-q", "-B", "one", "main")
198+ oneHead := r.commit(author, "one.txt", "1\n", "one")
199+ r.push("one")
200+ r.sh("checkout", "-q", "-B", "two", "main")
201+ twoHead := r.commit(author, "two.txt", "2\n", "two")
202+ r.push("two")
203+ one, two := r.open("one"), r.open("two")
204+ r.waitState(one.Number, domain.MergeabilityUpToDate, main, oneHead)
205+ r.waitState(two.Number, domain.MergeabilityUpToDate, main, twoHead)
206+
207+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, one.Number, ports.MergeParams{HeadID: oneHead})
208+ require.NoError(t, err)
209+ m := r.waitState(two.Number, domain.MergeabilityBehind, oneHead, twoHead)
210+ assert.Equal(t, 1, m.Behind)
211+ _, err = r.record(one.Number)
212+ assert.ErrorIs(t, err, domain.ErrNotFound, "the merged request keeps no record")
213+}
214+
215+func TestMergeabilityClosedRequestRecordDeleted(t *testing.T) {
216+ r := newMergeRig(t)
217+ r.seed()
218+ r.feature("feat")
219+ mr := r.open("feat")
220+ r.svc.ReconcileForTest(r.repo)
221+ _, err := r.record(mr.Number)
222+ require.NoError(t, err)
223+
224+ _, err = r.mrs.Close(r.ctx, mrW, r.repo, mr.Number)
225+ require.NoError(t, err)
226+ _, err = r.record(mr.Number)
227+ assert.ErrorIs(t, err, domain.ErrNotFound)
228+ r.svc.ReconcileForTest(r.repo) // a late check writes nothing
229+ _, err = r.record(mr.Number)
230+ assert.ErrorIs(t, err, domain.ErrNotFound)
231+}
232+
233+func TestMergeabilityStartupRescanComputesMissingRecords(t *testing.T) {
234+ r := newMergeRig(t)
235+ main := r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ _, err := r.record(mr.Number)
239+ require.ErrorIs(t, err, domain.ErrNotFound)
240+
241+ // A fresh service has received no OnOpened or Read trigger.
242+ svc := usecase.NewMergeability(r.ctx, usecase.MergeabilityConfig{
243+ Store: r.store.MergeRequests(), Meta: r.store, Git: r.git, Bus: r.bus, Clock: fixedClock{},
244+ })
245+ defer svc.Close(context.Background())
246+ svc.Start()
247+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
248+}
249+
250+func TestMergeabilityNeedsNoCI(t *testing.T) {
251+ // The service is built without any CI: a target move still updates the
252+ // record, and the repository gets no pull ref (the service has no CI to create a run with).
253+ r := newMergeRig(t)
254+ r.svc.Start()
255+ main := r.seed()
256+ head := r.feature("feat")
257+ mr := r.open("feat")
258+ r.waitState(mr.Number, domain.MergeabilityUpToDate, main, head)
259+ r.sh("checkout", "-q", "main")
260+ main2 := r.commit(author, "d.txt", "dee\n", "add d")
261+ r.push("main")
262+ r.svc.OnBranchesMoved(r.ctx, r.repo, []domain.RefUpdate{{Old: main.String(), New: main2.String(), Ref: "refs/heads/main"}})
263+ r.waitState(mr.Number, domain.MergeabilityBehind, main2, head)
264+
265+ assert.Empty(t, r.run(r.bare(), r.env(author), "for-each-ref", "refs/pull"))
266+}
267+
268+// --- unit tests over mocks ---
269+
270+type mockRig struct {
271+ store *mocks.MergeRequestStore
272+ meta *mocks.RepoMetaStore
273+ git *mocks.MergeGit
274+ bus *mocks.EventBus
275+ svc *usecase.Mergeability
276+ mr domain.MergeRequest
277+}
278+
279+func newMockRig(t *testing.T) *mockRig {
280+ t.Helper()
281+ repo := name(t, "proj")
282+ r := &mockRig{
283+ store: mocks.NewMergeRequestStore(t), meta: mocks.NewRepoMetaStore(t), git: mocks.NewMergeGit(t), bus: mocks.NewEventBus(t),
284+ mr: domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen},
285+ }
286+ r.store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{r.mr}, nil).Maybe()
287+ r.store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound).Maybe()
288+ r.meta.EXPECT().List(mock.Anything).Return(nil, nil).Maybe()
289+ r.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil).Maybe()
290+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
291+ Store: r.store, Meta: r.meta, Git: r.git, Bus: r.bus, Clock: fixedClock{},
292+ })
293+ t.Cleanup(func() { r.svc.Close(context.Background()) })
294+ return r
295+}
296+
297+func TestMergeabilityDedupsTriggers(t *testing.T) {
298+ r := newMockRig(t)
299+ repo := r.mr.Repo
300+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
301+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
302+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
303+ computed := make(chan struct{}, 1)
304+ var computes atomic.Int32
305+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).RunAndReturn(
306+ func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID) (bool, error) {
307+ computes.Add(1)
308+ computed <- struct{}{}
309+ return true, nil
310+ })
311+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
312+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
313+
314+ for range 50 {
315+ r.svc.OnOpened(context.Background(), r.mr) // queued before any worker runs
316+ }
317+ r.svc.Start()
318+ <-computed
319+ r.svc.Close(context.Background()) // the workers have each looked at the queue once more
320+ assert.EqualValues(t, 1, computes.Load())
321+}
322+
323+func TestMergeabilityNoWriteForClosedRequest(t *testing.T) {
324+ r := newMockRig(t)
325+ repo := r.mr.Repo
326+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
327+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
328+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
329+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
330+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once() // closed meanwhile
331+ r.svc.ReconcileForTest(repo) // no event, no further write
332+}
333+
334+func TestMergeabilityBoundsConflictPathsAndBehind(t *testing.T) {
335+ t.Run("conflict paths", func(t *testing.T) {
336+ r := newMockRig(t)
337+ repo := r.mr.Repo
338+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
339+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
340+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
341+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
342+ var paths []string
343+ for i := 0; i < domain.MaxConflictPaths+3; i++ {
344+ paths = append(paths, strings.Repeat("p", i+1))
345+ }
346+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{ConflictPaths: paths}, nil)
347+ var put domain.Mergeability
348+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
349+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
350+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
351+ r.svc.ReconcileForTest(repo)
352+ assert.Equal(t, domain.MergeabilityConflict, put.State)
353+ assert.Len(t, put.ConflictPaths, domain.MaxConflictPaths)
354+ assert.True(t, put.ConflictPathsTruncated)
355+ })
356+ t.Run("behind", func(t *testing.T) {
357+ r := newMockRig(t)
358+ repo := r.mr.Repo
359+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
360+ r.git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
361+ r.git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
362+ r.git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(false, nil)
363+ r.git.EXPECT().MergeTree(mock.Anything, repo, tip, head).Return(domain.MergeTreeResult{Clean: true}, nil)
364+ r.git.EXPECT().CountBehind(mock.Anything, repo, head, tip, domain.MaxBehindCount+1).Return(domain.MaxBehindCount+1, nil)
365+ var put domain.Mergeability
366+ r.store.EXPECT().PutMergeability(mock.Anything, mock.Anything).RunAndReturn(
367+ func(_ context.Context, m domain.Mergeability) error { put = m; return nil })
368+ r.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return()
369+ r.svc.ReconcileForTest(repo)
370+ assert.Equal(t, domain.MaxBehindCount, put.Behind)
371+ assert.True(t, put.BehindCapped)
372+ })
373+}
374+
375+// A transient Git failure is retried once after the backoff, by rescanning
376+// the repository through the queue.
377+func TestMergeabilityRetriesATransientFailureWithBackoff(t *testing.T) {
378+ repo := name(t, "proj")
379+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
380+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
381+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
382+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
383+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
384+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
385+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
386+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
387+ var fails atomic.Int32
388+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").RunAndReturn(
389+ func(context.Context, domain.RepoName, string) (domain.ObjectID, error) {
390+ if fails.Add(1) == 1 {
391+ return domain.ObjectID{}, errors.New("transient")
392+ }
393+ return head, nil
394+ })
395+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
396+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
397+ published := make(chan struct{})
398+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
399+
400+ asked, fire := make(chan time.Duration, 4), make(chan time.Time)
401+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
402+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
403+ After: func(d time.Duration) <-chan time.Time { asked <- d; return fire },
404+ })
405+ t.Cleanup(func() { svc.Close(context.Background()) })
406+ svc.Start()
407+ svc.OnOpened(context.Background(), mr)
408+
409+ assert.Equal(t, time.Second, <-asked)
410+ select {
411+ case <-published:
412+ t.Fatal("published before the backoff elapsed")
413+ default:
414+ }
415+ fire <- time.Time{}
416+ <-published
417+ assert.Empty(t, asked, "one retry was enough and only one was scheduled")
418+}
419+
420+// Close stops a pending retry timer's goroutine.
421+func TestMergeabilityCloseStopsPendingRetries(t *testing.T) {
422+ repo := name(t, "proj")
423+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
424+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
425+ store.EXPECT().ListOpen(mock.Anything, repo).Return(nil, errors.New("db down"))
426+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo}, nil)
427+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
428+ asked := make(chan time.Duration, 4)
429+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
430+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{},
431+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
432+ })
433+ svc.Start()
434+ svc.OnOpened(context.Background(), mr)
435+ <-asked
436+ done := make(chan struct{})
437+ go func() { svc.Close(context.Background()); close(done) }()
438+ <-done // Close waits for the timer goroutine: it returns only if it stopped
439+}
440+
441+// Workers are not stopped by the cancellation of the context given to the
442+// constructor; Close stops them.
443+func TestMergeabilityWorkersOutliveTheParentContext(t *testing.T) {
444+ repo := name(t, "proj")
445+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
446+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
447+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
448+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
449+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
450+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(nil)
451+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
452+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
453+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
454+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
455+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
456+ published := make(chan struct{})
457+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(context.Context, domain.Event) { close(published) }).Return().Once()
458+
459+ parent, cancel := context.WithCancel(context.Background())
460+ svc := usecase.NewMergeability(parent, usecase.MergeabilityConfig{Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}})
461+ svc.Start()
462+ cancel()
463+ svc.OnOpened(context.Background(), mr)
464+ <-published // computed although the parent is cancelled
465+ svc.Close(context.Background())
466+}
467+
468+// Failures of many requests of 3 repositories schedule at most one retry per
469+// repository on a single scheduler goroutine.
470+func TestMergeabilityRetriesAreBounded(t *testing.T) {
471+ const repos = 3
472+ store, meta := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t)
473+ barrier := name(t, "barrier")
474+ done := make(chan struct{})
475+ store.EXPECT().ListOpen(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) { close(done); return nil, nil }).Once()
476+ store.EXPECT().ListOpen(mock.Anything, mock.Anything).Return(nil, errors.New("db down"))
477+ meta.EXPECT().Get(mock.Anything, mock.Anything).Return(domain.Repo{}, nil)
478+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
479+ asked := make(chan time.Duration, repos)
480+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
481+ Store: store, Meta: meta, Clock: fixedClock{}, Workers: 1,
482+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
483+ })
484+ svc.Start()
485+ for r := range repos {
486+ repo := name(t, "proj"+strconv.Itoa(r))
487+ for range 20 {
488+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: repo})
489+ }
490+ }
491+ svc.OnOpened(context.Background(), domain.MergeRequest{Repo: barrier})
492+ <-done
493+ // The shared scheduler only arms the earliest pending retry.
494+ <-asked
495+ svc.Close(context.Background())
496+ assert.Equal(t, repos, svc.ScheduledRetries())
497+ assert.Equal(t, repos, svc.RetryAttempts())
498+}
499+
500+// retryRig is a service over mocks whose only repository holds request !7,
501+// whose read fails for its first failFor calls. Each repository rescan signals passes.
502+type retryRig struct {
503+ svc *usecase.Mergeability
504+ repo domain.RepoName
505+ reads atomic.Int32
506+ failFor atomic.Int32
507+ passes chan struct{}
508+ afters chan chan time.Time
509+ barrier chan struct{}
510+}
511+
512+func newRetryRig(t *testing.T) *retryRig {
513+ t.Helper()
514+ r := &retryRig{repo: name(t, "proj"), passes: make(chan struct{}, 32), afters: make(chan chan time.Time, 32), barrier: make(chan struct{})}
515+ store, meta, git, bus := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t), mocks.NewEventBus(t)
516+ r.failFor.Store(1 << 30)
517+ meta.EXPECT().Get(mock.Anything, r.repo).Return(domain.Repo{Name: r.repo}, nil)
518+ store.EXPECT().ListOpen(mock.Anything, r.repo).RunAndReturn(
519+ func(context.Context, domain.RepoName) ([]domain.MergeRequest, error) {
520+ defer func() { r.passes <- struct{}{} }()
521+ if r.reads.Add(1) <= r.failFor.Load() {
522+ return nil, errors.New("db down")
523+ }
524+ return nil, nil
525+ })
526+ barrier := name(t, "barrier")
527+ meta.EXPECT().Get(mock.Anything, barrier).RunAndReturn(func(context.Context, domain.RepoName) (domain.Repo, error) {
528+ r.barrier <- struct{}{}
529+ return domain.Repo{}, domain.ErrNotFound
530+ })
531+ meta.EXPECT().List(mock.Anything).Return([]domain.Repo{{Name: r.repo}}, nil)
532+ r.svc = usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
533+ Store: store, Meta: meta, Git: git, Bus: bus, Clock: fixedClock{}, Workers: 1,
534+ After: func(time.Duration) <-chan time.Time { ch := make(chan time.Time, 1); r.afters <- ch; return ch },
535+ })
536+ t.Cleanup(func() { r.svc.Close(context.Background()) })
537+ return r
538+}
539+
540+// next fires the pending retry and waits for the rescan it queues.
541+func (r *retryRig) next() {
542+ ch := <-r.afters
543+ ch <- time.Time{}
544+ <-r.passes
545+}
546+
547+func (r *retryRig) settled(t *testing.T) {
548+ t.Helper()
549+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: name(t, "barrier")})
550+ <-r.barrier
551+ require.Zero(t, r.svc.RetryAttempts())
552+ require.Zero(t, r.svc.ScheduledRetries())
553+}
554+
555+// A persistent failure is retried 5 times, then given up with no state kept.
556+func TestMergeabilityGivesUpAfterFiveRetries(t *testing.T) {
557+ r := newRetryRig(t)
558+ r.svc.Start()
559+ <-r.passes // the startup rescan fails
560+ for range 5 {
561+ r.next()
562+ }
563+ r.settled(t)
564+ assert.Empty(t, r.afters, "no sixth retry")
565+ assert.Empty(t, r.passes)
566+}
567+
568+// A successful full rescan resets the budget: a later failure gets 5 again.
569+func TestMergeabilitySuccessfulPassForgetsTheRetryBudget(t *testing.T) {
570+ r := newRetryRig(t)
571+ r.failFor.Store(2) // the first two reads fail, the third succeeds
572+ r.svc.Start()
573+ <-r.passes
574+ r.next() // second failing pass
575+ r.next() // success
576+ r.settled(t)
577+ r.failFor.Store(1 << 30)
578+ r.svc.OnOpened(context.Background(), domain.MergeRequest{Repo: r.repo})
579+ <-r.passes
580+ for range 5 {
581+ r.next()
582+ }
583+ r.settled(t)
584+ assert.Empty(t, r.afters)
585+ assert.Empty(t, r.passes)
586+}
587+
588+// stepClock is a clock the test moves.
589+type stepClock struct{ at atomic.Int64 }
590+
591+func (c *stepClock) Now() time.Time { return time.Unix(0, c.at.Load()).UTC() }
592+func (c *stepClock) advance(d time.Duration) { c.at.Add(int64(d)) }
593+
594+// An expired timer must not consume a replacement retry of its repository.
595+func TestMergeabilityStaleTimerKeepsReplacementRetry(t *testing.T) {
596+ repo := name(t, "proj")
597+ meta := mocks.NewRepoMetaStore(t)
598+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
599+ clock := &stepClock{}
600+ clock.advance(time.Hour)
601+ armed := make(chan time.Duration, 8)
602+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
603+ Meta: meta, Clock: clock, Workers: 1,
604+ After: func(d time.Duration) <-chan time.Time { armed <- d; return make(chan time.Time) },
605+ })
606+ t.Cleanup(func() { svc.Close(context.Background()) })
607+ svc.Start()
608+
609+ svc.RetryForTest(repo)
610+ assert.Equal(t, time.Second, <-armed, "the scheduler armed the timer of retry A")
611+ oldDue, ok := svc.RetryDueForTest(repo)
612+ require.True(t, ok)
613+
614+ svc.RetryDoneForTest(repo)
615+ clock.advance(10 * time.Second)
616+ svc.RetryForTest(repo)
617+ newDue, ok := svc.RetryDueForTest(repo)
618+ require.True(t, ok)
619+ require.True(t, newDue.After(oldDue))
620+
621+ // The old timer fires while the replacement is not yet due.
622+ assert.Empty(t, svc.PopDueForTest(repo, oldDue))
623+ assert.Equal(t, 1, svc.ScheduledRetries())
624+ // The timer armed for the replacement itself releases it.
625+ assert.Equal(t, []domain.RepoName{repo}, svc.PopDueForTest(repo, newDue))
626+ assert.Zero(t, svc.ScheduledRetries())
627+}
628+
629+func reconcileMocks(t *testing.T, repo domain.RepoName) (*mocks.MergeRequestStore, *mocks.RepoMetaStore, *mocks.MergeGit, domain.MergeRequest) {
630+ t.Helper()
631+ mr := domain.MergeRequest{Repo: repo, Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}
632+ store, meta, git := mocks.NewMergeRequestStore(t), mocks.NewRepoMetaStore(t), mocks.NewMergeGit(t)
633+ meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, CreatedAt: now}, nil)
634+ store.EXPECT().ListOpen(mock.Anything, repo).Return([]domain.MergeRequest{mr}, nil)
635+ return store, meta, git, mr
636+}
637+
638+func TestMergeabilityDisappearedBranchDeletesTheRecord(t *testing.T) {
639+ repo := name(t, "proj")
640+ store, meta, git, _ := reconcileMocks(t, repo)
641+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
642+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(fakeOID(t, "b"), nil)
643+ store.EXPECT().DeleteMergeability(mock.Anything, repo, int64(7)).Return(nil).Once()
644+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
645+ Store: store, Meta: meta, Git: git, Clock: fixedClock{},
646+ })
647+ t.Cleanup(func() { svc.Close(context.Background()) })
648+ assert.True(t, svc.ReconcileForTest(repo))
649+}
650+
651+func TestMergeabilityConflictMakesThePassFailAndRetries(t *testing.T) {
652+ repo := name(t, "proj")
653+ head, tip := fakeOID(t, "a"), fakeOID(t, "b")
654+ store, meta, git, mr := reconcileMocks(t, repo)
655+ meta.EXPECT().List(mock.Anything).Return(nil, nil)
656+ git.EXPECT().BranchHead(mock.Anything, repo, "feat").Return(head, nil)
657+ git.EXPECT().BranchHead(mock.Anything, repo, "main").Return(tip, nil)
658+ git.EXPECT().IsAncestor(mock.Anything, repo, tip, head).Return(true, nil)
659+ store.EXPECT().GetMergeability(mock.Anything, repo, int64(7)).Return(domain.Mergeability{}, domain.ErrNotFound)
660+ store.EXPECT().PutMergeability(mock.Anything, mock.Anything).Return(domain.ErrConflict)
661+
662+ asked := make(chan time.Duration, 4)
663+ svc := usecase.NewMergeability(context.Background(), usecase.MergeabilityConfig{
664+ Store: store, Meta: meta, Git: git, Clock: fixedClock{}, Workers: 1,
665+ After: func(d time.Duration) <-chan time.Time { asked <- d; return make(chan time.Time) },
666+ })
667+ t.Cleanup(func() { svc.Close(context.Background()) })
668+ assert.False(t, svc.ReconcileForTest(repo))
669+
670+ svc.Start()
671+ svc.OnOpened(context.Background(), mr)
672+ assert.Equal(t, time.Second, <-asked, "the conflict scheduled a retry")
673+}
M internal/usecase/mergerequests.go +246 -70
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "math"
11+ "runtime/debug"
1112 "slices"
1213 "strconv"
1314 "strings"
@@ -56,9 +57,15 @@ type MergeRequests struct {
5657 pulls ports.PullRequestTrigger // nil: no pull_request workflows
5758 tips ports.CIRefTipStore // nil: who merges is not recorded
5859 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
60+ mergeab MergeabilityHook // nil: no mergeability
5961
6062 mu sync.Mutex
6163 locks map[domain.RepoName]chan struct{}
64+
65+ // after tracks the post-merge side effects running off the request path.
66+ after sync.WaitGroup
67+ // afterGate, when set by a test, runs first in the post-merge goroutine.
68+ afterGate func()
6269 }
6370
6471 var (
@@ -95,13 +102,16 @@ type MergeRequestsConfig struct {
95102 // deleted source branch) before the merge does, as a push does
96103 // (domain.CIRefTip). Optional.
97104 RefTips ports.CIRefTipStore
105+ // Mergeability is queued when a request is opened, closed or merged and
106+ // read by Get and List. Optional: without it Mergeability stays zero.
107+ Mergeability MergeabilityHook
98108 }
99109
100110 // NewMergeRequests builds the merge request use cases.
101111 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
102112 return &MergeRequests{
103113 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
104- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips,
114+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks, pulls: cfg.PullRequests, tips: cfg.RefTips, mergeab: cfg.Mergeability,
105115 locks: make(map[domain.RepoName]chan struct{}),
106116 }
107117 }
@@ -113,6 +123,21 @@ func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
113123 return m
114124 }
115125
126+// Wait waits for the post-merge side effects still running (source branch
127+// deletion, events, CI and mergeability hooks), or for ctx to end. Call it
128+// before closing what they use.
129+func (m *MergeRequests) Wait(ctx context.Context) {
130+ done := make(chan struct{})
131+ go func() {
132+ m.after.Wait()
133+ close(done)
134+ }()
135+ select {
136+ case <-done:
137+ case <-ctx.Done():
138+ }
139+}
140+
116141 // lockRepo takes the merge lock of a repository, waiting until ctx ends. The
117142 // merges of one repository, and the creation of its merge requests, run one at
118143 // a time. The returned function releases the lock.
@@ -255,6 +280,9 @@ func (m *MergeRequests) Create(ctx context.Context, p domain.Principal, name dom
255280 return domain.MergeRequest{}, err
256281 }
257282 m.bus.Publish(ctx, domain.MergeRequestOpened{MergeRequest: mr, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
283+ if m.mergeab != nil {
284+ m.mergeab.OnOpened(ctx, mr)
285+ }
258286 if m.pulls != nil {
259287 // The commits the opening is for are the ones of now.
260288 base, _ := m.branchOrZero(ctx, name, target)
@@ -335,10 +363,25 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
335363 return (params.State == "" || mr.State == params.State) && (after == 0 || mr.Number < after)
336364 },
337365 encodeMRToken)
366+ tips := map[string]domain.ObjectID{} // one BranchHead per distinct target
338367 for i, mr := range page.MergeRequests {
339368 if page.MergeRequests[i], err = m.withHead(ctx, mr); err != nil {
340369 return ports.MergeRequestPage{}, err
341370 }
371+ if m.mergeab == nil || !mr.IsOpen() {
372+ continue
373+ }
374+ tip, ok := tips[mr.Target]
375+ if !ok {
376+ if tip, err = m.branchOrZero(ctx, name, mr.Target); err != nil {
377+ return ports.MergeRequestPage{}, err
378+ }
379+ tips[mr.Target] = tip
380+ }
381+ if page.Mergeability == nil {
382+ page.Mergeability = map[int64]domain.Mergeability{}
383+ }
384+ page.Mergeability[mr.Number] = m.mergeab.Read(ctx, page.MergeRequests[i], tip)
342385 }
343386 return page, nil
344387 }
@@ -395,6 +438,28 @@ func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name
395438 return n, nil
396439 }
397440
441+// Mergeability implements ports.MergeRequestService: the mergeability of an
442+// open merge request for the current tips, queued when there is no up-to-date
443+// result. It is the zero value for a request that is not open, or without a
444+// mergeability service.
445+func (m *MergeRequests) Mergeability(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.Mergeability, error) {
446+ _, mr, err := m.get(ctx, p, name, number)
447+ if err != nil {
448+ return domain.Mergeability{}, err
449+ }
450+ if m.mergeab == nil || !mr.IsOpen() {
451+ return domain.Mergeability{}, nil
452+ }
453+ if mr, err = m.withHead(ctx, mr); err != nil {
454+ return domain.Mergeability{}, err
455+ }
456+ tip, err := m.branchOrZero(ctx, name, mr.Target)
457+ if err != nil {
458+ return domain.Mergeability{}, err
459+ }
460+ return m.mergeab.Read(ctx, mr, tip), nil
461+}
462+
398463 func encodeMRToken(last domain.MergeRequest) string {
399464 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
400465 }
@@ -465,6 +530,13 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain
465530 return nil
466531 }
467532 d := ports.MergeRequestDetail{MergeRequest: mr}
533+ if m.mergeab != nil && mr.IsOpen() {
534+ tip, err := m.branchOrZero(ctx, name, mr.Target)
535+ if err != nil {
536+ return ports.MergeRequestDetail{}, err
537+ }
538+ d.Mergeability = m.mergeab.Read(ctx, mr, tip)
539+ }
468540 if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil {
469541 return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err)
470542 }
@@ -849,30 +921,77 @@ func (m *MergeRequests) MarkRead(ctx context.Context, p domain.Principal, name d
849921 // domain.ErrPreconditionFailed when the request is not open, the mode is not
850922 // allowed by the settings of the repository, the source branch no longer
851923 // points to the head ("branch moved, read again") or is gone, the target is
852-// gone or is not an ancestor of the head (the author rebases and pushes
853-// again), or the branch brings nothing. Checks come with CI (CI-1): there are
854-// none yet, so the setting that lets failing checks block the merge has
855-// nothing to act on.
924+// gone, the mode's own condition does not hold (see below), or the branch
925+// brings nothing. Checks come with CI (CI-1): there are none yet, so the
926+// setting that lets failing checks block the merge has nothing to act on.
856927 //
857-// A fast-forward moves the target to the head, with the old value as a
858-// precondition. A squash writes one commit with the tree of the head, the
859-// target as parent and the author and message of domain.NewSquashCommit,
860-// signed by the instance key, and moves the target to it the same way.
928+// The conditions are always checked with Git, under the lock, never from the
929+// stored mergeability. A fast-forward needs the target to be an ancestor of
930+// the head (the author rebases and pushes again) and moves the target to the
931+// head, with the old value as a precondition. A squash and a merge commit need
932+// the head to merge cleanly into the target (a behind branch is fine). A squash
933+// writes one commit with the merged tree, the target as parent and the author
934+// and message of domain.NewSquashCommit; a merge commit has two parents (the
935+// target, then the head) and the message of domain.NewMergeCommit. Both are
936+// signed by the instance key and move the target the same way.
861937 //
862938 // With DeleteSource the merge is also refused, before anything changes, when
863939 // the source is the default branch or the source or target of another open
864-// request. After the merge the source is deleted if it still points to the
865-// head; the PushEvent then carries the deletion too. A failed deletion is
866-// logged and leaves the branch: the merge stands and SourceDeleted is false.
940+// request.
941+//
942+// Only the merge itself runs before Merge returns, under the lock. Afterwards,
943+// off the request path and without the lock: the deletion of the source branch
944+// (if it still points to the head; a failure is logged and leaves the branch),
945+// MergeRequestMerged then a PushEvent (which carries the deletion too), and
946+// the CI and mergeability hooks. Wait waits for them. SourceDeleted therefore
947+// only says that the deletion was requested.
867948 func (m *MergeRequests) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
868- mr, deleted, err := m.merge(ctx, p, name, number, params)
869- return ports.MergeResult{MergeRequest: mr, SourceDeleted: deleted}, err
949+ mr, err := m.merge(ctx, p, name, number, params)
950+ return ports.MergeResult{MergeRequest: mr, SourceDeleted: err == nil && params.DeleteSource}, err
870951 }
871952
872-func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, bool, error) {
953+// maxConflictPathsShown is how many conflicting paths a refusal lists.
954+const maxConflictPathsShown = 5
955+
956+// checkMode is the authoritative per-mode check of a merge, done with Git. It
957+// returns the tree a squash or merge commit gets (zero for a fast-forward).
958+func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID) (domain.ObjectID, error) {
959+ if mode == domain.MergeModeFastForward {
960+ ok, err := m.git.IsAncestor(ctx, mr.Repo, tip, head)
961+ if err != nil {
962+ return domain.ObjectID{}, err
963+ }
964+ if !ok {
965+ return domain.ObjectID{}, fmt.Errorf("%w: the target has moved: rebase and push again, or merge with squash or a merge commit", domain.ErrPreconditionFailed)
966+ }
967+ return domain.ObjectID{}, nil
968+ }
969+ res, err := m.git.MergeTree(ctx, mr.Repo, tip, head)
970+ if err != nil {
971+ return domain.ObjectID{}, err
972+ }
973+ res = res.Bounded()
974+ if !res.Clean {
975+ what := "no common history"
976+ if len(res.ConflictPaths) > 0 {
977+ shown := res.ConflictPaths
978+ if len(shown) > maxConflictPathsShown {
979+ shown = shown[:maxConflictPathsShown]
980+ }
981+ what = strings.Join(shown, ", ")
982+ if len(shown) < len(res.ConflictPaths) || res.Truncated {
983+ what += ", ..."
984+ }
985+ }
986+ return domain.ObjectID{}, fmt.Errorf("%w: conflicts with %s: %s: rebase and push again", domain.ErrPreconditionFailed, mr.Target, what)
987+ }
988+ return res.Tree, nil
989+}
990+
991+func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) {
873992 repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge)
874993 if err != nil {
875- return domain.MergeRequest{}, false, err
994+ return domain.MergeRequest{}, err
876995 }
877996 mode := params.Mode
878997 if mode == "" {
@@ -886,116 +1005,134 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
8861005 v.Add("head_id", "is required")
8871006 }
8881007 if err := v.Err(); err != nil {
889- return domain.MergeRequest{}, false, err
1008+ return domain.MergeRequest{}, err
8901009 }
8911010
8921011 unlock, err := m.lockRepo(ctx, name)
8931012 if err != nil {
894- return domain.MergeRequest{}, false, err
1013+ return domain.MergeRequest{}, err
8951014 }
896- defer unlock()
1015+ var once sync.Once
1016+ release := func() { once.Do(unlock) }
1017+ defer release()
8971018
8981019 mr, err := m.store.Get(ctx, name, number)
8991020 if err != nil {
900- return domain.MergeRequest{}, false, err
1021+ return domain.MergeRequest{}, err
9011022 }
9021023 // A merge moves the target and may delete the source: a token limited to
9031024 // branch prefixes may do so only within them.
9041025 if !p.AllowsRef("refs/heads/" + mr.Target) {
905- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
1026+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
9061027 }
9071028 if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
908- return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
1029+ return domain.MergeRequest{}, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
9091030 }
9101031 if !mr.IsOpen() {
911- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
1032+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
9121033 }
9131034 if mr.IsDraft() {
914- return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
1035+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
9151036 }
9161037 // The settings are read again under the lock: a change applies at once.
9171038 repo, err = m.meta.Get(ctx, name)
9181039 if err != nil {
9191040 if errors.Is(err, domain.ErrNotFound) {
920- return domain.MergeRequest{}, false, err // deleted meanwhile
1041+ return domain.MergeRequest{}, err // deleted meanwhile
9211042 }
922- return domain.MergeRequest{}, false, fmt.Errorf("get repository %q: %w", name, err)
1043+ return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err)
9231044 }
9241045 settings := repo.Settings
9251046 if settings.IsZero() {
9261047 settings = domain.DefaultRepoSettings()
9271048 }
9281049 if !settings.Allows(mode) {
929- return domain.MergeRequest{}, false, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
1050+ return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name)
9301051 }
9311052
9321053 head, err := m.branchOrZero(ctx, name, mr.Source)
9331054 switch {
9341055 case err != nil:
935- return domain.MergeRequest{}, false, err
1056+ return domain.MergeRequest{}, err
9361057 case head.IsZero():
937- return domain.MergeRequest{}, false, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
1058+ return domain.MergeRequest{}, fmt.Errorf("%w: the source branch %q no longer exists", domain.ErrPreconditionFailed, mr.Source)
9381059 case head != params.HeadID:
939- return domain.MergeRequest{}, false, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
1060+ return domain.MergeRequest{}, fmt.Errorf("%w: branch moved, read again: %q is at %s, not %s", domain.ErrPreconditionFailed, mr.Source, head, params.HeadID)
9401061 }
9411062 tip, err := m.branchOrZero(ctx, name, mr.Target)
9421063 switch {
9431064 case err != nil:
944- return domain.MergeRequest{}, false, err
1065+ return domain.MergeRequest{}, err
9451066 case tip.IsZero():
946- return domain.MergeRequest{}, false, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
1067+ return domain.MergeRequest{}, fmt.Errorf("%w: the target branch %q no longer exists", domain.ErrPreconditionFailed, mr.Target)
9471068 case tip == head:
948- return domain.MergeRequest{}, false, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
1069+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already is at %s", domain.ErrPreconditionFailed, mr.Target, head)
9491070 }
950- switch ok, err := m.git.IsAncestor(ctx, name, tip, head); {
951- case err != nil:
952- return domain.MergeRequest{}, false, err
953- case !ok:
954- return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
1071+ // A head behind the target (or at it) has nothing to merge, in any mode.
1072+ if behind, err := m.git.IsAncestor(ctx, name, head, tip); err != nil {
1073+ return domain.MergeRequest{}, err
1074+ } else if behind {
1075+ return domain.MergeRequest{}, fmt.Errorf("%w: nothing to merge, %q already contains %s", domain.ErrPreconditionFailed, mr.Target, head)
1076+ }
1077+ tree, err := m.checkMode(ctx, mr, mode, tip, head)
1078+ if err != nil {
1079+ return domain.MergeRequest{}, err
9551080 }
9561081 if settings.FailingChecksBlock {
9571082 if err := m.checksAllow(ctx, name, head); err != nil {
958- return domain.MergeRequest{}, false, err
1083+ return domain.MergeRequest{}, err
9591084 }
9601085 }
9611086 if params.DeleteSource {
9621087 if err := m.checkDeletable(ctx, name, mr); err != nil {
963- return domain.MergeRequest{}, false, err
1088+ return domain.MergeRequest{}, err
9641089 }
9651090 }
9661091
967- merged, err := m.moveTarget(ctx, mr, mode, tip, head, p)
1092+ merged, err := m.moveTarget(ctx, mr, mode, tip, head, tree, p)
9681093 if err != nil {
969- return domain.MergeRequest{}, false, err
1094+ return domain.MergeRequest{}, err
9701095 }
9711096 m.recordPush(ctx, name) // the target moved, whatever happens next
9721097 mr, err = m.markMerged(ctx, name, number, mode, tip, head, merged)
9731098 if err != nil {
974- return domain.MergeRequest{}, false, err
1099+ return domain.MergeRequest{}, err
9751100 }
9761101 log := zerowrap.FromCtx(ctx)
9771102 log.Info().Str("repo", name.String()).Int64("number", number).Str("mode", mode.String()).
9781103 Str("target", mr.Target).Str("commit", merged.String()).
9791104 Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("merge request merged")
980- updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
981- deleted := false
982- if params.DeleteSource {
983- // The merge is done and recorded: a failure here keeps the branch. Who
984- // pushed the source goes first (the branch is gone either way for a
985- // schedule; a kept branch merely has no record: not trusted).
986- if err := recordRefTips(ctx, m.tips, name, []domain.RefUpdate{{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source}}, "", domain.CIPusher{}, false); err != nil {
987- log.Warn().Err(err).Str("repo", name.String()).Str("branch", mr.Source).Msg("the record of the source branch was kept")
988- }
989- switch err := m.git.DeleteBranch(context.WithoutCancel(ctx), name, mr.Source, head); {
990- case err != nil:
991- log.Warn().Err(err).Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
992- Msg("source branch kept after the merge")
993- default:
994- deleted = true
995- updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
996- log.Info().Str("repo", name.String()).Int64("number", number).Str("branch", mr.Source).
997- Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1105+
1106+ // The merge is done and recorded. The rest runs without the lock and after
1107+ // the caller got its answer.
1108+ release()
1109+ bg := context.WithoutCancel(ctx)
1110+ m.after.Add(1)
1111+ go func() {
1112+ defer m.after.Done()
1113+ defer func() {
1114+ if r := recover(); r != nil {
1115+ log := zerowrap.FromCtx(bg)
1116+ log.Error().Str("repo", name.String()).Int64("number", number).
1117+ Str("panic", fmt.Sprint(r)).Bytes("stack", debug.Stack()).Msg("post-merge side effects panicked")
1118+ }
1119+ }()
1120+ if m.afterGate != nil {
1121+ m.afterGate()
9981122 }
1123+ m.afterMerge(bg, p, repo, mr, head, tip, merged, params.DeleteSource)
1124+ }()
1125+ return mr, nil
1126+}
1127+
1128+// afterMerge is what follows a merge off the request path: errors are logged,
1129+// never returned. mr is the merged request, tip the old target and merged the
1130+// new one.
1131+func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo domain.Repo, mr domain.MergeRequest, head, tip, merged domain.ObjectID, deleteSource bool) {
1132+ name := repo.Name
1133+ updates := []domain.RefUpdate{{Old: tip.String(), New: merged.String(), Ref: "refs/heads/" + mr.Target}}
1134+ if deleteSource && m.deleteSource(ctx, p, name, mr, head) {
1135+ updates = append(updates, domain.RefUpdate{Old: head.String(), New: domain.ZeroOID, Ref: "refs/heads/" + mr.Source})
9991136 }
10001137 at := mr.UpdatedAt
10011138 m.bus.Publish(ctx, domain.MergeRequestMerged{MergeRequest: mr, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
@@ -1003,14 +1140,47 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
10031140 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
10041141 })
10051142 if m.pulls != nil {
1006- m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
1143+ m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
10071144 }
10081145 if m.ci != nil {
10091146 // GitHub runs the "push" workflows of a merge, with the owner as actor;
10101147 // the run is trusted with what the merging principal may read.
10111148 m.ci.OnMerge(ctx, name, updates, p)
10121149 }
1013- return mr, deleted, nil
1150+ if m.mergeab != nil {
1151+ m.mergeab.OnBranchesMoved(ctx, name, updates) // the other requests of the moved target
1152+ }
1153+}
1154+
1155+// deleteSource deletes the source branch of a merged request if it is still
1156+// head and still unused, and reports whether it did. The eligibility is checked
1157+// again and the branch deleted in one critical section of the merge lock: a
1158+// request opened on the branch since the merge, or a push to it, keeps it. The
1159+// record of who pushed the branch goes only after a deletion, and only if it
1160+// still names head.
1161+func (m *MergeRequests) deleteSource(ctx context.Context, p domain.Principal, name domain.RepoName, mr domain.MergeRequest, head domain.ObjectID) bool {
1162+ log := zerowrap.FromCtx(ctx).With().Str("repo", name.String()).Int64("number", mr.Number).Str("branch", mr.Source).Logger()
1163+ unlock, err := m.lockRepo(ctx, name)
1164+ if err != nil {
1165+ log.Warn().Err(err).Msg("source branch kept after the merge")
1166+ return false
1167+ }
1168+ defer unlock()
1169+ if err := m.checkDeletable(ctx, name, mr); err != nil {
1170+ log.Warn().Err(err).Msg("source branch kept after the merge")
1171+ return false
1172+ }
1173+ if err := m.git.DeleteBranch(ctx, name, mr.Source, head); err != nil {
1174+ log.Warn().Err(err).Msg("source branch kept after the merge")
1175+ return false
1176+ }
1177+ if m.tips != nil {
1178+ if err := m.tips.DeleteRefTipIf(ctx, name, "refs/heads/"+mr.Source, head.String()); err != nil && !errors.Is(err, domain.ErrNotFound) {
1179+ log.Warn().Err(err).Msg("the record of the source branch was kept")
1180+ }
1181+ }
1182+ log.Info().Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("source branch deleted after the merge")
1183+ return true
10141184 }
10151185
10161186 // WhileOpen implements ports.PullGate: fn runs under the merge lock, so a
@@ -1103,10 +1273,20 @@ func (m *MergeRequests) recordPush(ctx context.Context, name domain.RepoName) {
11031273 }
11041274
11051275 // moveTarget moves the target branch from tip to the head (fast-forward) or to
1106-// a new signed squash commit, and returns the new tip.
1107-func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
1276+// a new signed squash or merge commit with the given tree, and returns the new tip.
1277+func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest, mode domain.MergeMode, tip, head, tree domain.ObjectID, p domain.Principal) (domain.ObjectID, error) {
11081278 newTip := head
1109- if mode == domain.MergeModeSquash {
1279+ switch mode {
1280+ case domain.MergeModeMerge:
1281+ commit := domain.NewMergeCommit(mr, tree, tip, head, m.committer, m.clock.Now())
1282+ armored, err := m.signer.Sign(commit.Payload())
1283+ if err != nil {
1284+ return domain.ObjectID{}, fmt.Errorf("sign the merge commit of !%d in %q: %w", mr.Number, mr.Repo, err)
1285+ }
1286+ if newTip, err = m.git.WriteCommit(ctx, mr.Repo, commit.Signed(armored)); err != nil {
1287+ return domain.ObjectID{}, err
1288+ }
1289+ case domain.MergeModeSquash:
11101290 commits, more, err := m.git.ListRange(ctx, mr.Repo, tip, head, domain.MaxSquashCommits)
11111291 if err != nil {
11121292 return domain.ObjectID{}, err
@@ -1114,10 +1294,6 @@ func (m *MergeRequests) moveTarget(ctx context.Context, mr domain.MergeRequest,
11141294 if more {
11151295 return domain.ObjectID{}, fmt.Errorf("%w: the branch has more than %d commits to squash", domain.ErrPreconditionFailed, domain.MaxSquashCommits)
11161296 }
1117- tree, err := m.git.CommitTree(ctx, mr.Repo, head)
1118- if err != nil {
1119- return domain.ObjectID{}, err
1120- }
11211297 commit := domain.NewSquashCommit(mr, tree, tip, commits, m.committer, m.clock.Now())
11221298 armored, err := m.signer.Sign(commit.Payload())
11231299 if err != nil {
M internal/usecase/mergerequests_branches_test.go +1 -0
@@ -67,6 +67,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) {
6767 res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
6868 require.NoError(t, err)
6969 assert.True(t, res.SourceDeleted)
70+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
7071 assert.Equal(t, head, r.branchHead("agent/claude/base"))
7172 _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
7273 assert.ErrorIs(t, err, domain.ErrNotFound)
M internal/usecase/mergerequests_delete_test.go +149 -1
@@ -3,8 +3,13 @@
33 package usecase_test
44
55 import (
6+ "bytes"
67 "context"
8+ "sync"
79 "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
813
914 "github.com/stretchr/testify/assert"
1015 "github.com/stretchr/testify/require"
@@ -31,6 +36,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) {
3136 require.NoError(t, err)
3237 assert.True(t, res.SourceDeleted)
3338 assert.Equal(t, domain.MRStateMerged, res.State)
39+ r.mrs.Wait(r.ctx) // the deletion happens after Merge returned
3440 _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
3541 assert.ErrorIs(t, err, domain.ErrNotFound, "the branch is gone")
3642
@@ -157,6 +163,7 @@ func TestCreateAfterAMergeDeletedTheBranch(t *testing.T) {
157163 mr := r.open("feat")
158164 _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
159165 require.NoError(t, err)
166+ r.mrs.Wait(r.ctx) // the deletion runs after Merge returned
160167 _, err = r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "late", Source: "feat"})
161168 require.ErrorIs(t, err, domain.ErrInvalid)
162169 assert.Contains(t, fieldsOf(t, err), "source_branch")
@@ -192,11 +199,152 @@ func TestMergeKeepsASourceThatMovedAfterTheMerge(t *testing.T) {
192199
193200 res, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
194201 require.NoError(t, err, "the merge stands")
195- assert.False(t, res.SourceDeleted)
202+ assert.True(t, res.SourceDeleted, "the deletion was requested")
196203 assert.Equal(t, domain.MRStateMerged, res.State)
204+ mrs.Wait(r.ctx)
197205 assert.Equal(t, head, r.branchHead("main"))
198206 assert.Equal(t, moved, r.branchHead("feat"), "the late commit is kept")
199207 evs := r.drain()
200208 require.Len(t, evs, 2)
201209 assert.Len(t, evs[1].(domain.PushEvent).Updates, 1, "only the target moved")
202210 }
211+
212+// afterMergeRig builds merge request use cases over the rig with ref-tip
213+// records, whose post-merge goroutine waits for the returned release function.
214+func (r *mrRig) afterMergeRig(extra func(*usecase.MergeRequestsConfig)) (mrs *usecase.MergeRequests, release func()) {
215+ gate := make(chan struct{})
216+ cfg := usecase.MergeRequestsConfig{
217+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: nil,
218+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
219+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, RefTips: r.store.CI(),
220+ }
221+ if extra != nil {
222+ extra(&cfg)
223+ }
224+ mrs = usecase.NewMergeRequests(cfg)
225+ mrs.SetAfterMergeGate(func() { <-gate })
226+ var once sync.Once
227+ return mrs, func() { once.Do(func() { close(gate) }) }
228+}
229+
230+// A request opened on the source after the merge, before the deletion runs,
231+// keeps the branch (GO-002).
232+func TestMergeKeepsTheSourceWhenAnotherRequestOpensOnItMeanwhile(t *testing.T) {
233+ t.Parallel()
234+ r := newMRRig(t)
235+ r.seed()
236+ head := r.feature("feat")
237+ mr := r.open("feat")
238+ r.sh("checkout", "-q", "-B", "release", "main")
239+ r.push("release")
240+ mrs, release := r.afterMergeRig(nil)
241+ defer release()
242+
243+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
244+ require.NoError(t, err)
245+ _, err = mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "to release", Source: "feat", Target: "release"})
246+ require.NoError(t, err, "the merge released the lock; the deletion has not run")
247+ release()
248+ mrs.Wait(r.ctx)
249+ assert.Equal(t, head, r.branchHead("feat"), "the branch is kept")
250+}
251+
252+// A push that moves the source after the merge keeps both the branch and the
253+// record of who pushed it (GO-003).
254+func TestMergeKeepsTheSourceAndItsRecordWhenAPushMovesItMeanwhile(t *testing.T) {
255+ t.Parallel()
256+ r := newMRRig(t)
257+ r.seed()
258+ head := r.feature("feat")
259+ mr := r.open("feat")
260+ mrs, release := r.afterMergeRig(nil)
261+ defer release()
262+
263+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
264+ require.NoError(t, err)
265+ r.sh("checkout", "-q", "feat")
266+ moved := r.commit(author, "later.txt", "later\n", "pushed meanwhile")
267+ r.push("feat")
268+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: moved.String(), Actor: "ada"}))
269+ release()
270+ mrs.Wait(r.ctx)
271+
272+ assert.Equal(t, moved, r.branchHead("feat"), "the branch is kept")
273+ tip, err := r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
274+ require.NoError(t, err, "the record of the new push is kept")
275+ assert.Equal(t, moved.String(), tip.Sha)
276+}
277+
278+// The record of the source goes with the branch, once it is deleted.
279+func TestMergeRemovesTheSourceRecordAfterDeletingIt(t *testing.T) {
280+ t.Parallel()
281+ r := newMRRig(t)
282+ r.seed()
283+ head := r.feature("feat")
284+ mr := r.open("feat")
285+ mrs, release := r.afterMergeRig(nil)
286+ release()
287+ require.NoError(t, r.store.CI().PutRefTip(r.ctx, domain.CIRefTip{Repo: r.repo, Ref: "refs/heads/feat", Sha: head.String(), Actor: "ada"}))
288+
289+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
290+ require.NoError(t, err)
291+ mrs.Wait(r.ctx)
292+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
293+ require.ErrorIs(t, err, domain.ErrNotFound)
294+ _, err = r.store.CI().GetRefTip(r.ctx, r.repo, "refs/heads/feat")
295+ assert.ErrorIs(t, err, domain.ErrNotFound)
296+}
297+
298+// panicHooks is a CI trigger whose merge hook panics.
299+type panicHooks struct{}
300+
301+func (panicHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
302+func (panicHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
303+ panic("boom from ci")
304+}
305+
306+func TestMergeLogsAPanickingSideEffectAndWaitReturns(t *testing.T) {
307+ t.Parallel()
308+ r := newMRRig(t)
309+ r.seed()
310+ head := r.feature("feat")
311+ mr := r.open("feat")
312+ mrs, release := r.afterMergeRig(func(c *usecase.MergeRequestsConfig) { c.CI = panicHooks{} })
313+ release()
314+ var logs bytes.Buffer
315+ ctx := zerowrap.WithCtx(r.ctx, zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &logs}))
316+
317+ _, err := mrs.Merge(ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
318+ require.NoError(t, err)
319+ waited := make(chan struct{})
320+ go func() { mrs.Wait(context.Background()); close(waited) }()
321+ select {
322+ case <-waited:
323+ case <-time.After(10 * time.Second):
324+ t.Fatal("Wait did not return after a panicking side effect")
325+ }
326+ assert.Contains(t, logs.String(), "post-merge side effects panicked")
327+ assert.Contains(t, logs.String(), "boom from ci")
328+}
329+
330+// A head behind the target has nothing to merge, whatever the mode (GO-007).
331+func TestMergeRefusesAHeadBehindTheTarget(t *testing.T) {
332+ t.Parallel()
333+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge, domain.MergeModeFastForward} {
334+ t.Run(string(mode), func(t *testing.T) {
335+ t.Parallel()
336+ r := newMRRig(t)
337+ main := r.seed()
338+ r.sh("checkout", "-q", "-B", "old", "main~1")
339+ r.push("old")
340+ old := r.branchHead("old")
341+ require.NotEqual(t, main, old)
342+ mr := r.open("old")
343+
344+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{Mode: mode, HeadID: old})
345+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
346+ assert.Contains(t, err.Error(), "nothing to merge")
347+ assert.Equal(t, main, r.branchHead("main"), "no commit was made")
348+ })
349+ }
350+}
M internal/usecase/mergerequests_integration_test.go +11 -6
@@ -207,6 +207,7 @@ func (r *mrRig) open(branch string) domain.MergeRequest {
207207 // subscriber before Publish returns: whatever a returned call published is
208208 // already in the channel.
209209 func (r *mrRig) drain() []domain.Event {
210+ r.mrs.Wait(r.ctx) // the events of a merge are published after it returned
210211 var out []domain.Event
211212 for {
212213 select {
@@ -959,11 +960,10 @@ func TestMergeRefusals(t *testing.T) {
959960 r.push("main")
960961 tip := r.branchHead("main")
961962
962- for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash} {
963- _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
964- require.ErrorIs(t, err, domain.ErrPreconditionFailed, mode)
965- assert.Contains(t, err.Error(), "rebase", mode)
966- }
963+ // Only a fast-forward needs the target to be an ancestor.
964+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeFastForward})
965+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
966+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again")
967967 assert.Equal(t, tip, r.branchHead("main"))
968968 d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
969969 require.NoError(t, err)
@@ -1047,8 +1047,13 @@ func TestConcurrentMergesSerialize(t *testing.T) {
10471047 const n = 6
10481048 heads := map[int64]domain.ObjectID{}
10491049 for i := range n {
1050+ // Every branch writes the same file differently: once one is merged the
1051+ // others neither fast-forward nor merge cleanly.
10501052 b := fmt.Sprintf("f%d", i)
1051- heads[r.openFeature(b).Number] = r.branchHead(b)
1053+ r.sh("checkout", "-q", "-B", b, "main")
1054+ r.commit(author, "same.txt", b+"\n", "add "+b)
1055+ r.push(b)
1056+ heads[r.open(b).Number] = r.branchHead(b)
10521057 }
10531058
10541059 type outcome struct {
A internal/usecase/mergerequests_merge_test.go +293 -0
@@ -0,0 +1,293 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "crypto/ed25519"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/sshsig"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// gateHooks is a CI and a pull_request trigger that block on a channel, to
24+// tell what runs before Merge returns.
25+type gateHooks struct {
26+ release chan struct{}
27+ mu sync.Mutex
28+ calls []string
29+}
30+
31+func newGateHooks() *gateHooks { return &gateHooks{release: make(chan struct{})} }
32+
33+func (g *gateHooks) note(s string) {
34+ <-g.release
35+ g.mu.Lock()
36+ defer g.mu.Unlock()
37+ g.calls = append(g.calls, s)
38+}
39+
40+func (g *gateHooks) ran() []string {
41+ g.mu.Lock()
42+ defer g.mu.Unlock()
43+ return append([]string(nil), g.calls...)
44+}
45+
46+func (g *gateHooks) OnPush(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {}
47+
48+func (g *gateHooks) OnMerge(context.Context, domain.RepoName, []domain.RefUpdate, domain.Principal) {
49+ g.note("ci")
50+}
51+
52+// OnPullRequest blocks for the closing of a request only: Create reports the
53+// opening on the request path.
54+func (g *gateHooks) OnPullRequest(_ context.Context, _ domain.RepoName, ev ports.PullRequestEvent, _ domain.Principal) {
55+ if ev.Action == domain.PullClosed {
56+ g.note("pulls")
57+ }
58+}
59+
60+// gated builds merge request use cases over the rig whose CI and pull hooks
61+// block until g.release is closed.
62+func (r *mrRig) gated(g *gateHooks) *usecase.MergeRequests {
63+ signer, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
64+ require.NoError(r.t, err)
65+ return usecase.NewMergeRequests(usecase.MergeRequestsConfig{
66+ Meta: r.store, Store: r.store.MergeRequests(), Repos: r.git, Git: r.git, Signer: signer,
67+ Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
68+ Bus: r.bus, Clock: fixedClock{}, Maint: r.sched, CI: g, PullRequests: g,
69+ })
70+}
71+
72+// behind moves main on with a commit of its own (a file the feature does not
73+// touch), and returns the new tip.
74+func (r *mrRig) behind() domain.ObjectID {
75+ r.sh("checkout", "-q", "main")
76+ r.commit(author, "news.txt", "n\n", "main moved")
77+ r.push("main")
78+ return r.branchHead("main")
79+}
80+
81+func TestMergeModesWhenBehind(t *testing.T) {
82+ t.Parallel()
83+ t.Run("fast-forward is refused", func(t *testing.T) {
84+ t.Parallel()
85+ r := newMRRig(t)
86+ r.seed()
87+ head := r.feature("feat")
88+ mr := r.open("feat")
89+ tip := r.behind()
90+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
91+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92+ assert.Contains(t, err.Error(), "the target has moved: rebase and push again, or merge with squash or a merge commit")
93+ assert.Equal(t, tip, r.branchHead("main"))
94+ })
95+
96+ t.Run("squash merges the merged tree", func(t *testing.T) {
97+ t.Parallel()
98+ r := newMRRig(t)
99+ r.seed()
100+ head := r.feature("feat")
101+ mr := r.open("feat")
102+ tip := r.behind()
103+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
104+ require.NoError(t, err)
105+ sq := res.MergedCommitID.String()
106+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
107+ assert.Equal(t, tip.String(), g("rev-parse", sq+"^"), "the current tip is the parent")
108+ files := g("ls-tree", "-r", "--name-only", sq)
109+ assert.Contains(t, files, "news.txt", "the target's own change is kept")
110+ assert.Contains(t, files, "c.txt", "the branch's change is there")
111+ r.verifyCommit(sq)
112+ })
113+
114+ t.Run("merge commit has two parents, the merge tree and a good signature", func(t *testing.T) {
115+ t.Parallel()
116+ r := newMRRig(t)
117+ r.seed()
118+ head := r.feature("feat")
119+ mr := r.open("feat")
120+ tip := r.behind()
121+ want := r.run(r.bare(), r.env(author), "merge-tree", "--write-tree", tip.String(), head.String())
122+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
123+ require.NoError(t, err)
124+ assert.Equal(t, domain.MergeModeMerge, res.MergeMode)
125+ assert.Equal(t, tip, res.BaseID)
126+ id := res.MergedCommitID.String()
127+ assert.Equal(t, id, r.branchHead("main").String())
128+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
129+ assert.Equal(t, tip.String()+" "+head.String(), g("log", "-1", "--format=%P", id), "parents: target, head")
130+ assert.Equal(t, strings.TrimSpace(want), g("rev-parse", id+"^{tree}"))
131+ assert.Equal(t, "Merge branch 'feat' into main (!1)\n\nAdd feat", g("log", "-1", "--format=%B", id))
132+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%an <%ae>", id))
133+ assert.Equal(t, "ermite <ermite@git.example.org>", g("log", "-1", "--format=%cn <%ce>", id))
134+ r.verifyCommit(id)
135+ })
136+
137+ t.Run("merge commit when up to date", func(t *testing.T) {
138+ t.Parallel()
139+ r := newMRRig(t)
140+ main := r.seed()
141+ head := r.feature("feat")
142+ mr := r.open("feat")
143+ res, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeMerge})
144+ require.NoError(t, err)
145+ id := res.MergedCommitID.String()
146+ g := func(args ...string) string { return r.run(r.bare(), r.env(author), args...) }
147+ assert.Equal(t, main.String()+" "+head.String(), g("log", "-1", "--format=%P", id))
148+ assert.Equal(t, g("rev-parse", head.String()+"^{tree}"), g("rev-parse", id+"^{tree}"))
149+ })
150+}
151+
152+func TestMergeRefusedOnConflictAndNoCommonHistory(t *testing.T) {
153+ t.Parallel()
154+ conflict := func(t *testing.T) (*mrRig, domain.MergeRequest, domain.ObjectID, domain.ObjectID) {
155+ r := newMRRig(t)
156+ r.seed()
157+ head := r.feature("feat") // changes line 2 of a.txt
158+ mr := r.open("feat")
159+ r.sh("checkout", "-q", "main")
160+ r.commit(author, "a.txt", "one\nTWO\nthree\n", "main changes a too")
161+ r.push("main")
162+ return r, mr, head, r.branchHead("main")
163+ }
164+ for _, mode := range []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash, domain.MergeModeMerge} {
165+ t.Run("conflict "+string(mode), func(t *testing.T) {
166+ t.Parallel()
167+ r, mr, head, tip := conflict(t)
168+ _, err := r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
169+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
170+ if mode != domain.MergeModeFastForward {
171+ assert.Contains(t, err.Error(), "conflicts with main: a.txt: rebase and push again")
172+ }
173+ assert.Equal(t, tip, r.branchHead("main"))
174+ d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
175+ require.NoError(t, err)
176+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
177+ })
178+ }
179+
180+ for _, mode := range []domain.MergeMode{domain.MergeModeSquash, domain.MergeModeMerge} {
181+ t.Run("no common history "+string(mode), func(t *testing.T) {
182+ t.Parallel()
183+ r := newMRRig(t)
184+ r.seed()
185+ r.sh("checkout", "-q", "--orphan", "island")
186+ r.commit(author, "island.txt", "i\n", "an unrelated root")
187+ r.push("island")
188+ head := r.branchHead("island")
189+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "island", Source: "island"})
190+ if err != nil {
191+ t.Skipf("a request without common history cannot be opened: %v", err)
192+ }
193+ tip := r.branchHead("main")
194+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: mode})
195+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
196+ assert.Contains(t, err.Error(), "no common history")
197+ assert.Equal(t, tip, r.branchHead("main"))
198+ })
199+ }
200+}
201+
202+func TestMergeReturnsBeforeTheSideEffects(t *testing.T) {
203+ t.Parallel()
204+ r := newMRRig(t)
205+ r.seed()
206+ head := r.feature("feat")
207+ mr := r.open("feat")
208+ r.drain()
209+ g := newGateHooks()
210+ mrs := r.gated(g)
211+
212+ done := make(chan error, 1)
213+ go func() {
214+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
215+ done <- err
216+ }()
217+ select {
218+ case err := <-done:
219+ require.NoError(t, err, "Merge does not wait for the blocked hooks")
220+ case <-time.After(10 * time.Second):
221+ t.Fatal("Merge waited for the side effects")
222+ }
223+ // The merge is complete and recorded though the hooks have not run.
224+ d, err := mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{})
225+ require.NoError(t, err)
226+ assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State)
227+ assert.Equal(t, d.MergeRequest.MergedCommitID, r.branchHead("main"))
228+ assert.Empty(t, g.ran())
229+
230+ // Wait waits for them: it times out while they are blocked, then returns.
231+ ctx, cancel := context.WithTimeout(r.ctx, 50*time.Millisecond)
232+ start := time.Now()
233+ mrs.Wait(ctx)
234+ cancel()
235+ assert.GreaterOrEqual(t, time.Since(start), 40*time.Millisecond, "Wait waits for the pending side effects")
236+
237+ waited := make(chan struct{})
238+ go func() { mrs.Wait(context.Background()); close(waited) }()
239+ select {
240+ case <-waited:
241+ t.Fatal("Wait returned with side effects pending")
242+ case <-time.After(50 * time.Millisecond):
243+ }
244+ close(g.release)
245+ select {
246+ case <-waited:
247+ case <-time.After(10 * time.Second):
248+ t.Fatal("Wait did not return after the side effects ended")
249+ }
250+ assert.ElementsMatch(t, []string{"ci", "pulls"}, g.ran())
251+ _, err = r.git.BranchHead(r.ctx, r.repo, "feat")
252+ assert.ErrorIs(t, err, domain.ErrNotFound, "the source was deleted")
253+
254+ // Merged, then pushed, with the deletion in the push.
255+ evs := r.drain()
256+ require.Len(t, evs, 2)
257+ assert.IsType(t, domain.MergeRequestMerged{}, evs[0])
258+ push, ok := evs[1].(domain.PushEvent)
259+ require.True(t, ok)
260+ assert.Len(t, push.Updates, 2)
261+}
262+
263+func TestMergeReleasesTheLockBeforeTheSideEffects(t *testing.T) {
264+ t.Parallel()
265+ r := newMRRig(t)
266+ r.seed()
267+ head := r.feature("feat")
268+ mr := r.open("feat")
269+ r.sh("checkout", "-q", "main")
270+ other := r.openFeature("other")
271+ g := newGateHooks()
272+ defer func() {
273+ close(g.release)
274+ }()
275+ mrs := r.gated(g)
276+
277+ _, err := mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
278+ require.NoError(t, err)
279+ t.Cleanup(func() { mrs.Wait(context.Background()) })
280+ require.Empty(t, g.ran(), "the side effects are still blocked")
281+
282+ // Create and another Merge need the repository lock: they must finish.
283+ ctx, cancel := context.WithTimeout(r.ctx, 10*time.Second)
284+ defer cancel()
285+ r.sh("checkout", "-q", "-B", "third", "main")
286+ r.commit(author, "third.txt", "t\n", "third")
287+ r.push("third")
288+ _, err = mrs.Create(ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "third", Source: "third"})
289+ require.NoError(t, err, "Create is not blocked by the side effects of a merge")
290+ _, err = mrs.Merge(ctx, mrM, r.repo, other.Number, ports.MergeParams{HeadID: r.branchHead("other"), Mode: domain.MergeModeMerge})
291+ require.NoError(t, err, "a second merge is not blocked either")
292+ assert.Empty(t, g.ran())
293+}
M internal/usecase/pushes.go +23 -11
@@ -20,17 +20,18 @@ const pushRetries = 5
2020
2121 // Pushes are the use cases around a Git push.
2222 type Pushes struct {
23- meta ports.RepoMetaStore
24- repos *Repos
25- git ports.GitRepos
26- bus ports.EventBus
27- clock ports.Clock
28- maint ports.MaintenanceScheduler
29- auth ports.Revalidator
30- fresh ports.RepoFreshener // nil: the reader needs no telling
31- ci ports.WorkflowTrigger // nil: no CI
32- wf ports.WorkflowGuard // nil: pushes by job tokens are refused
33- tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
23+ meta ports.RepoMetaStore
24+ repos *Repos
25+ git ports.GitRepos
26+ bus ports.EventBus
27+ clock ports.Clock
28+ maint ports.MaintenanceScheduler
29+ auth ports.Revalidator
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31+ ci ports.WorkflowTrigger // nil: no CI
32+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
33+ tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
34+ mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked
3435 }
3536
3637 var (
@@ -59,6 +60,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5960 return u
6061 }
6162
63+// WithMergeability makes Record queue the mergeability checks of the merge
64+// requests whose branches the push moved. Call it before the first push; it
65+// returns u.
66+func (u *Pushes) WithMergeability(t ports.MergeabilityTrigger) *Pushes {
67+ u.mergeab = t
68+ return u
69+}
70+
6271 // WithRefTips makes Check record who is about to move each branch and tag
6372 // (domain.CIRefTip), before Git moves it. Call it before the first push; it
6473 // returns u.
@@ -233,6 +242,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
233242 if u.ci != nil {
234243 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
235244 }
245+ if u.mergeab != nil {
246+ u.mergeab.OnBranchesMoved(ctx, name, updates) // queues only
247+ }
236248 u.bus.Publish(ctx, domain.PushEvent{
237249 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
238250 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M proto/ermite/storage/merge_request.proto +28 -0
@@ -15,6 +15,7 @@ enum MergeMode {
1515 MERGE_MODE_UNSPECIFIED = 0;
1616 MERGE_MODE_FAST_FORWARD = 1;
1717 MERGE_MODE_SQUASH = 2;
18+ MERGE_MODE_MERGE = 3;
1819 }
1920
2021 // RepoSettings are the merge settings of a repository, stored inside Repo.
@@ -24,6 +25,11 @@ message RepoSettings {
2425 bool failing_checks_block = 2;
2526 // Who may write issues; unspecified reads as off.
2627 IssuesMode issues = 3;
28+ // Set on every write since merge commits exist: the modes are the explicit
29+ // choice of the owner. When false (settings stored before), exactly
30+ // {fast-forward, squash} was the old default and gains MERGE_MODE_MERGE on
31+ // read.
32+ bool merge_modes_v2 = 4;
2733 }
2834
2935 // IssuesMode says who may write issues in a repository.
@@ -104,3 +110,25 @@ message Comment {
104110 message Sequence {
105111 int64 last = 1;
106112 }
113+
114+// MergeabilityState is the outcome of a mergeability check. "Checking" is
115+// never stored.
116+enum MergeabilityState {
117+ MERGEABILITY_STATE_UNSPECIFIED = 0;
118+ MERGEABILITY_STATE_UP_TO_DATE = 1;
119+ MERGEABILITY_STATE_BEHIND = 2;
120+ MERGEABILITY_STATE_CONFLICT = 3;
121+}
122+
123+// Mergeability is stored under the key "mrmerge/<repo>/<number:010d>": a
124+// derived, discardable record of an open merge request.
125+message Mergeability {
126+ MergeabilityState state = 1;
127+ string target_id = 2;
128+ string head_id = 3;
129+ int32 behind = 4;
130+ bool behind_capped = 5;
131+ repeated string conflict_paths = 6;
132+ bool conflict_paths_truncated = 7;
133+ google.protobuf.Timestamp checked_at = 8;
134+}
M skills/ermite/CLI.md +5 -4
@@ -11,7 +11,7 @@ Errors are one line on standard error (`ermite: ...`) with exit status 1. `--jso
1111 | `repo list [--json]` | none | repositories you can see |
1212 | `repo show <name> [--json]` | none | |
1313 | `repo create <name> [--description TEXT] [--visibility public]` | `repo:write` | private by default |
14-| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; `--description ""` clears it; the branch must exist |
14+| `repo edit <name> [--description TEXT] [--visibility V] [--default-branch B] [--merge-modes fast-forward,squash,merge] [--failing-checks-block=false]` | `repo:admin` | only the given flags change; every merge mode is allowed by default, list the ones to keep; `--description ""` clears it; the branch must exist |
1515 | `repo delete <name> [--yes]` | `repo:admin` | |
1616
1717 Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created_at`, `pushed_at` (null before the first push), `merge_modes`, `failing_checks_block`.
@@ -56,12 +56,12 @@ A token can never grant more than the token that creates it. `--repo` (repeatabl
5656 |---|---|---|
5757 | `mr create <repo> --source B --title T [--target B] [--description TEXT\|-]` | `mr:write` | target defaults to the default branch; title of 120 characters max; `-` reads standard input |
5858 | `mr list <repo> [--state open\|merged\|closed\|all] [--json]` | none | default `open` |
59-| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers |
59+| `mr show <repo> <n> [--diff] [--since-last-read] [--mark-read] [--json]` | `mr:write` for `--mark-read` | commits, changed files and comments; `--diff` adds hunks with line numbers; the `mergeability` line (JSON `mergeability`, null unless open) is `up_to_date`, `behind` (N commits, merges cleanly), `conflict` (paths) or `checking` |
6060 | `mr comment <repo> <n> -m TEXT\|- [--path P --line N\|A-B --side old\|new] [--reply ID] [--approve\|--request-changes]` | `mr:write`; verdicts `mr:review` | Markdown note (8000 characters max); the lines must be in the current diff on one side; a deleted file uses its old path; `--reply` takes a root note (`12`, `#12` or `^12`), inherits its anchor and cannot carry a verdict; a verdict note may have no message |
6161 | `mr edit-comment <repo> <n> <comment-id> -m TEXT\|-` | `mr:write` | author only, open merge request; the verdict is unchanged |
6262 | `mr delete-comment <repo> <n> <comment-id>` | `mr:write` | author only; the text is lost, the verdict is kept |
6363 | `mr resolve <repo> <n> <comment-id> [--undo]` | `mr:write` | on the thread root, covers its replies; doing it twice is not an error |
64-| `mr merge <repo> <n> --head <full-id> [--squash] [--delete-branch]` | `mr:merge` | refused if the branch moved or the target is not an ancestor of the head; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
64+| `mr merge <repo> <n> --head <full-id> [--mode fast-forward\|squash\|merge] [--delete-branch]` | `mr:merge` | refused if the branch moved, if `fast-forward` and the target is not an ancestor of the head (behind: use `squash` or `merge`), or if `squash`/`merge` conflict; squash and merge commits are signed by the server key (`signing-key show`); answers once the target moved; `--delete-branch` deletes the source after the merge (refused for the default branch or a branch another open request uses; JSON adds `source_branch_deleted`) |
6565 | `mr close <repo> <n>` | `mr:write` | a closed merge request cannot be reopened |
6666
6767 `ermite mr create` and `ermite mr close` print a confirmation followed by the full
@@ -106,6 +106,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
106106 | `repo_created`, `repo_updated`, `repo_deleted` | `repo`, `repository` |
107107 | `pushed` | `repo`, `ref_updates` (`ref`, `old_id`, `new_id`; an empty id means created or deleted) |
108108 | `merge_request_opened`, `merge_request_merged`, `merge_request_closed` | `repo`, `merge_request` |
109+| `merge_request_mergeability` | `repo`, `number`, `mergeability` (`state`: `checking`, `up_to_date`, `behind`, `conflict`; `behind`, `behind_capped`, `conflict_paths`, `conflict_paths_truncated`); sent when the state changes |
109110 | `comment_added`, `comment_edited`, `comment_deleted`, `comment_resolved` | `repo`, `number`, `comment` (a verdict note has `verdict` set) |
110111 | `gap` | none: events were lost, reload the state |
111112
@@ -116,7 +117,7 @@ Workflow JSON: `repo`, `file` (always `.github/workflows/<file>`), `name` (the `
116117 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
117118 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
118119 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
119-| `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
120+| `signing-key show` | public key and `allowed_signers` line to check squash and merge commits with `git verify-commit` |
120121 | `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
121122 | `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |
122123 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
M skills/ermite/SKILL.md +2 -2
@@ -37,9 +37,9 @@ git push https://git.example.com/<repo>.git main # password = token,
3737 2. Read: `ermite mr show <repo> <n> --diff --json`. Keep `head_id`; take line numbers from the hunks.
3838 3. Comment on a line: `ermite mr comment <repo> <n> -m "text" --path <file> --line <n> --side new`; `--line 10-14` covers a range on one side. Without `--path` the comment is general. The line must appear in the diff. Notes are Markdown; `^N` refers to note N.
3939 4. End a review with a verdict (needs `mr:review`): `--approve` or `--request-changes`, alone or with a message. Verdicts are informative and cannot be edited; a new verdict replaces the previous one.
40-5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--squash` for one commit signed by the server and `--delete-branch` to delete the source branch afterwards.
40+5. Merge only when asked: `ermite mr merge <repo> <n> --head <head_id>`, with `--mode squash` for one commit or `--mode merge` for a merge commit (both signed by the server's key, see `ermite signing-key show`) and `--delete-branch` to delete the source branch afterwards.
4141
42-The merge is refused when the branch moved since `head_id` (review again) or when the target is not an ancestor of the head (rebase and push again). The repository may allow only one merge mode.
42+The merge is refused when the branch moved since `head_id` (review again) or, for `fast-forward`, when the target is not an ancestor of the head (rebase and push again, or use `squash`/`merge`), or when `squash`/`merge` conflict. `ermite mr show` prints the `mergeability` line (up to date, behind by N, conflict with paths). The repository may allow only some merge modes.
4343
4444 ### Act on a review
4545

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
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close