detached e9c6360

#29 feat(web): merge merge requests asynchronously with htmx

state
merged · cf26365 · squash
branch
feat/mr-async-merge → main
opened
bnema ·
updated

The merge request Merge action now runs through htmx for every mode (fast-forward, squash, merge) instead of a full-page POST and 303 reload.

  • The form posts asynchronously, drops double submits and shows a pending indicator; without JS it still posts normally.
  • htmx merges get OOB fragments (state badge + action area). Refusals, failures, expired sessions and rejected origins render a status in the merge box.
  • Other open tabs of the same MR receive the merged state through the live stream.
  • The checks-label observer watches the stable MR bar so it survives swaps.

Deferred: the fragment path still builds the full page view (diff, comments) before rendering only state/actions; a dedicated state view builder is a follow-up.

Checks: go test ./..., go vet, node jstest suite (198 pass). Not manually tested in a browser.

CommitSubjectAuthorDateSignatureStatus
e9c6360fix(web): show session refusals in the async merge box and keep a single merge error alertbriceGPG Verified
5fea81ffeat(web): merge merge requests asynchronously with htmx and live state updatesbriceGPG Verified

Checks 4

64 files · +1924 -2040
4/4
M docs/issues.md +1 -3
M docs/releases.md +5 -44
M go.mod +5 -5
M go.sum +8 -8
M internal/adapters/web/fragments.go +15 -0
M internal/adapters/web/inbox.go +3 -25
M internal/adapters/web/inbox.templ +1 -34
M internal/adapters/web/inbox_templ.go +173 -451
M internal/adapters/web/inbox_test.go +4 -6
D internal/adapters/web/jstest/keys.test.mjs +0 -29
M internal/adapters/web/jstest/mr.test.mjs +12 -4
M internal/adapters/web/keys.templ +0 -1
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/mr.go +7 -0
M internal/adapters/web/mr.templ +62 -29
A internal/adapters/web/mr_async_test.go +166 -0
M internal/adapters/web/mr_post.go +33 -1
M internal/adapters/web/mr_templ.go +1226 -1034
M internal/adapters/web/overlays.templ +0 -3
M internal/adapters/web/overlays_templ.go +39 -49
M internal/adapters/web/session.go +12 -2
M internal/adapters/web/shell.templ +1 -5
M internal/adapters/web/shell_templ.go +133 -152
M internal/adapters/web/static/css/60-mr.css +2 -0
M internal/adapters/web/static/js/ermite.js +2 -2
M internal/adapters/web/static/js/mr.js +3 -3
M internal/app/forge.go +1 -1
M internal/app/testdata/web/blob-binary.html +0 -1
M internal/app/testdata/web/blob-code.html +0 -1
M internal/app/testdata/web/blob-empty.html +0 -1
M internal/app/testdata/web/blob-image.html +0 -1
M internal/app/testdata/web/blob-markdown.html +0 -1
M internal/app/testdata/web/blob-svg.html +0 -1
M internal/app/testdata/web/blob-text.html +0 -1
M internal/app/testdata/web/blob-too-large.html +0 -1
M internal/app/testdata/web/blob-trojan.html +0 -1
M internal/app/testdata/web/commit-big.html +0 -1
M internal/app/testdata/web/commit-binary.html +0 -1
M internal/app/testdata/web/commit-merge.html +0 -1
M internal/app/testdata/web/commit-modify-split.html +0 -1
M internal/app/testdata/web/commit-modify.html +0 -1
M internal/app/testdata/web/commit-rename.html +0 -1
M internal/app/testdata/web/commit-root.html +0 -1
M internal/app/testdata/web/commit-trojan.html +0 -1
M internal/app/testdata/web/commits-page2.html +0 -1
M internal/app/testdata/web/commits-path.html +0 -1
M internal/app/testdata/web/commits-root.html +0 -1
M internal/app/testdata/web/commits-small.html +0 -1
M internal/app/testdata/web/empty.html +0 -1
M internal/app/testdata/web/home.html +0 -1
M internal/app/testdata/web/not-found-page.html +0 -1
@@ -267,7 +267,6 @@
267267 k-r from:body -> ermite.tab('r');
268268 k-m from:body -> ermite.tab('m');
269269 k-i from:body -> ermite.tab('i');
270- k-inbox from:body -> ermite.go('[data-inbox]');
271270 k-next from:body -> ermite.nextNote();
272271 k-split from:body -> ermite.layout('split');
273272 k-unified from:body -> ermite.layout('unified');
@@ -267,7 +267,6 @@
267 k-r from:body -> ermite.tab('r');267 k-r from:body -> ermite.tab('r');
268 k-m from:body -> ermite.tab('m');268 k-m from:body -> ermite.tab('m');
269 k-i from:body -> ermite.tab('i');269 k-i from:body -> ermite.tab('i');
270- k-inbox from:body -> ermite.go('[data-inbox]');
271 k-next from:body -> ermite.nextNote();270 k-next from:body -> ermite.nextNote();
272 k-split from:body -> ermite.layout('split');271 k-split from:body -> ermite.layout('split');
273 k-unified from:body -> ermite.layout('unified');272 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +0 -1
@@ -267,7 +267,6 @@
267267 k-r from:body -> ermite.tab('r');
268268 k-m from:body -> ermite.tab('m');
269269 k-i from:body -> ermite.tab('i');
270- k-inbox from:body -> ermite.go('[data-inbox]');
271270 k-next from:body -> ermite.nextNote();
272271 k-split from:body -> ermite.layout('split');
273272 k-unified from:body -> ermite.layout('unified');
@@ -267,7 +267,6 @@
267 k-r from:body -> ermite.tab('r');267 k-r from:body -> ermite.tab('r');
268 k-m from:body -> ermite.tab('m');268 k-m from:body -> ermite.tab('m');
269 k-i from:body -> ermite.tab('i');269 k-i from:body -> ermite.tab('i');
270- k-inbox from:body -> ermite.go('[data-inbox]');
271 k-next from:body -> ermite.nextNote();270 k-next from:body -> ermite.nextNote();
272 k-split from:body -> ermite.layout('split');271 k-split from:body -> ermite.layout('split');
273 k-unified from:body -> ermite.layout('unified');272 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +0 -1
@@ -503,7 +503,6 @@
503503 k-r from:body -> ermite.tab('r');
504504 k-m from:body -> ermite.tab('m');
505505 k-i from:body -> ermite.tab('i');
506- k-inbox from:body -> ermite.go('[data-inbox]');
507506 k-next from:body -> ermite.nextNote();
508507 k-split from:body -> ermite.layout('split');
509508 k-unified from:body -> ermite.layout('unified');
@@ -503,7 +503,6 @@
503 k-r from:body -> ermite.tab('r');503 k-r from:body -> ermite.tab('r');
504 k-m from:body -> ermite.tab('m');504 k-m from:body -> ermite.tab('m');
505 k-i from:body -> ermite.tab('i');505 k-i from:body -> ermite.tab('i');
506- k-inbox from:body -> ermite.go('[data-inbox]');
507 k-next from:body -> ermite.nextNote();506 k-next from:body -> ermite.nextNote();
508 k-split from:body -> ermite.layout('split');507 k-split from:body -> ermite.layout('split');
509 k-unified from:body -> ermite.layout('unified');508 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +0 -1
@@ -529,7 +529,6 @@
529529 k-r from:body -> ermite.tab('r');
530530 k-m from:body -> ermite.tab('m');
531531 k-i from:body -> ermite.tab('i');
532- k-inbox from:body -> ermite.go('[data-inbox]');
533532 k-next from:body -> ermite.nextNote();
534533 k-split from:body -> ermite.layout('split');
535534 k-unified from:body -> ermite.layout('unified');
@@ -529,7 +529,6 @@
529 k-r from:body -> ermite.tab('r');529 k-r from:body -> ermite.tab('r');
530 k-m from:body -> ermite.tab('m');530 k-m from:body -> ermite.tab('m');
531 k-i from:body -> ermite.tab('i');531 k-i from:body -> ermite.tab('i');
532- k-inbox from:body -> ermite.go('[data-inbox]');
533 k-next from:body -> ermite.nextNote();532 k-next from:body -> ermite.nextNote();
534 k-split from:body -> ermite.layout('split');533 k-split from:body -> ermite.layout('split');
535 k-unified from:body -> ermite.layout('unified');534 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +0 -1
@@ -443,7 +443,6 @@
443443 k-r from:body -> ermite.tab('r');
444444 k-m from:body -> ermite.tab('m');
445445 k-i from:body -> ermite.tab('i');
446- k-inbox from:body -> ermite.go('[data-inbox]');
447446 k-next from:body -> ermite.nextNote();
448447 k-split from:body -> ermite.layout('split');
449448 k-unified from:body -> ermite.layout('unified');
@@ -443,7 +443,6 @@
443 k-r from:body -> ermite.tab('r');443 k-r from:body -> ermite.tab('r');
444 k-m from:body -> ermite.tab('m');444 k-m from:body -> ermite.tab('m');
445 k-i from:body -> ermite.tab('i');445 k-i from:body -> ermite.tab('i');
446- k-inbox from:body -> ermite.go('[data-inbox]');
447 k-next from:body -> ermite.nextNote();446 k-next from:body -> ermite.nextNote();
448 k-split from:body -> ermite.layout('split');447 k-split from:body -> ermite.layout('split');
449 k-unified from:body -> ermite.layout('unified');448 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +0 -1
@@ -664,7 +664,6 @@
664664 k-r from:body -> ermite.tab('r');
665665 k-m from:body -> ermite.tab('m');
666666 k-i from:body -> ermite.tab('i');
667- k-inbox from:body -> ermite.go('[data-inbox]');
668667 k-next from:body -> ermite.nextNote();
669668 k-split from:body -> ermite.layout('split');
670669 k-unified from:body -> ermite.layout('unified');
@@ -664,7 +664,6 @@
664 k-r from:body -> ermite.tab('r');664 k-r from:body -> ermite.tab('r');
665 k-m from:body -> ermite.tab('m');665 k-m from:body -> ermite.tab('m');
666 k-i from:body -> ermite.tab('i');666 k-i from:body -> ermite.tab('i');
667- k-inbox from:body -> ermite.go('[data-inbox]');
668 k-next from:body -> ermite.nextNote();667 k-next from:body -> ermite.nextNote();
669 k-split from:body -> ermite.layout('split');668 k-split from:body -> ermite.layout('split');
670 k-unified from:body -> ermite.layout('unified');669 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +0 -1
@@ -440,7 +440,6 @@
440440 k-r from:body -> ermite.tab('r');
441441 k-m from:body -> ermite.tab('m');
442442 k-i from:body -> ermite.tab('i');
443- k-inbox from:body -> ermite.go('[data-inbox]');
444443 k-next from:body -> ermite.nextNote();
445444 k-split from:body -> ermite.layout('split');
446445 k-unified from:body -> ermite.layout('unified');
@@ -440,7 +440,6 @@
440 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
441 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442 k-i from:body -> ermite.tab('i');442 k-i from:body -> ermite.tab('i');
443- k-inbox from:body -> ermite.go('[data-inbox]');
444 k-next from:body -> ermite.nextNote();443 k-next from:body -> ermite.nextNote();
445 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
446 k-unified from:body -> ermite.layout('unified');445 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +0 -1
@@ -456,7 +456,6 @@
456456 k-r from:body -> ermite.tab('r');
457457 k-m from:body -> ermite.tab('m');
458458 k-i from:body -> ermite.tab('i');
459- k-inbox from:body -> ermite.go('[data-inbox]');
460459 k-next from:body -> ermite.nextNote();
461460 k-split from:body -> ermite.layout('split');
462461 k-unified from:body -> ermite.layout('unified');
@@ -456,7 +456,6 @@
456 k-r from:body -> ermite.tab('r');456 k-r from:body -> ermite.tab('r');
457 k-m from:body -> ermite.tab('m');457 k-m from:body -> ermite.tab('m');
458 k-i from:body -> ermite.tab('i');458 k-i from:body -> ermite.tab('i');
459- k-inbox from:body -> ermite.go('[data-inbox]');
460 k-next from:body -> ermite.nextNote();459 k-next from:body -> ermite.nextNote();
461 k-split from:body -> ermite.layout('split');460 k-split from:body -> ermite.layout('split');
462 k-unified from:body -> ermite.layout('unified');461 k-unified from:body -> ermite.layout('unified');
M internal/app/web_inbox_test.go +2 -32
@@ -8,7 +8,6 @@ import (
88 "net/http"
99 "strings"
1010 "testing"
11- "time"
1211
1312 "github.com/stretchr/testify/assert"
1413 "github.com/stretchr/testify/require"
@@ -43,51 +42,22 @@ func TestWebInboxEndToEnd(t *testing.T) {
4342 assert.Contains(t, empty.body, "no open merge requests.")
4443 assert.Contains(t, empty.body, "no open issues.")
4544
46- // A queued run is visible and its run-level events refresh the owner stream.
47- draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
48- return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
49- })
50- require.NoError(t, err)
51- runs := b.get("/-/inbox", as(token))
52- assert.Contains(t, runs.body, "Inbox build")
53- assert.Contains(t, runs.body, "<strong>1</strong> running")
54- assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
55-
5645 // The owner stream sees both repositories.
5746 stream := ownerStream(t, r.harness, token)
5847
59- r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
60- var runFragments strings.Builder
61- for range 6 {
62- runFragments.WriteString(nextFragment(t, stream).data)
63- }
64- assert.Contains(t, runFragments.String(), "Inbox build")
65- // Completion removes the row and updates the total in the same live fragments.
66- draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
67- _, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
68- require.NoError(t, err)
69- r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
70- runFragments.Reset()
71- for range 6 {
72- runFragments.WriteString(nextFragment(t, stream).data)
73- }
74- assert.NotContains(t, runFragments.String(), "Inbox build")
75- assert.Contains(t, runFragments.String(), "no runs in progress.")
76- assert.Contains(t, runFragments.String(), "<strong>0</strong> running")
77-
7848 root := domain.Root()
7949 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
8050 require.NoError(t, err)
8151 ev := nextFragment(t, stream)
8252 assert.Equal(t, "inbox", ev.name)
8353 assert.Contains(t, ev.data, `id="inbox-counts"`)
84- for range 5 { // the other fragments of this event
54+ for range 4 { // the other fragments of this event
8555 nextFragment(t, stream)
8656 }
8757 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
8858 require.NoError(t, err)
8959 var seen strings.Builder
90- for range 6 { // the six fragments of this event
60+ for range 5 { // the five fragments of this event
9161 seen.WriteString(nextFragment(t, stream).data)
9262 }
9363 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
@@ -8,7 +8,6 @@ import (
8 "net/http"8 "net/http"
9 "strings"9 "strings"
10 "testing"10 "testing"
11- "time"
12 11
13 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -43,51 +42,22 @@ func TestWebInboxEndToEnd(t *testing.T) {
43 assert.Contains(t, empty.body, "no open merge requests.")42 assert.Contains(t, empty.body, "no open merge requests.")
44 assert.Contains(t, empty.body, "no open issues.")43 assert.Contains(t, empty.body, "no open issues.")
45 44
46- // A queued run is visible and its run-level events refresh the owner stream.
47- draft, err := r.forge.store.CI().CreateRun(r.ctx, r.name("proj"), ".github/workflows/build.yml", func(id, number int64) ports.CIRunDraft {
48- return ports.CIRunDraft{Run: domain.CIRun{Repo: r.name("proj"), ID: id, Number: number, Attempt: 1, WorkflowName: "Inbox build", Status: domain.CIQueued, Ref: "refs/heads/main", Event: "push", CreatedAt: time.Now()}}
49- })
50- require.NoError(t, err)
51- runs := b.get("/-/inbox", as(token))
52- assert.Contains(t, runs.body, "Inbox build")
53- assert.Contains(t, runs.body, "<strong>1</strong> running")
54- assert.Contains(t, runs.body, `data-inbox href="/-/inbox"`)
55-
56 // The owner stream sees both repositories.45 // The owner stream sees both repositories.
57 stream := ownerStream(t, r.harness, token)46 stream := ownerStream(t, r.harness, token)
58 47
59- r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
60- var runFragments strings.Builder
61- for range 6 {
62- runFragments.WriteString(nextFragment(t, stream).data)
63- }
64- assert.Contains(t, runFragments.String(), "Inbox build")
65- // Completion removes the row and updates the total in the same live fragments.
66- draft.Run.Status, draft.Run.Conclusion = domain.CICompleted, domain.CISuccess
67- _, err = r.forge.store.CI().Apply(r.ctx, ports.CIWrite{Runs: []domain.CIRun{draft.Run}})
68- require.NoError(t, err)
69- r.forge.Bus.Publish(r.ctx, domain.RunUpdated{Run: draft.Run, At: time.Now(), Visibility: domain.VisibilityPublic, CreatedAt: time.Now()})
70- runFragments.Reset()
71- for range 6 {
72- runFragments.WriteString(nextFragment(t, stream).data)
73- }
74- assert.NotContains(t, runFragments.String(), "Inbox build")
75- assert.Contains(t, runFragments.String(), "no runs in progress.")
76- assert.Contains(t, runFragments.String(), "<strong>0</strong> running")
77-
78 root := domain.Root()48 root := domain.Root()
79 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})49 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
80 require.NoError(t, err)50 require.NoError(t, err)
81 ev := nextFragment(t, stream)51 ev := nextFragment(t, stream)
82 assert.Equal(t, "inbox", ev.name)52 assert.Equal(t, "inbox", ev.name)
83 assert.Contains(t, ev.data, `id="inbox-counts"`)53 assert.Contains(t, ev.data, `id="inbox-counts"`)
84- for range 5 { // the other fragments of this event54+ for range 4 { // the other fragments of this event
85 nextFragment(t, stream)55 nextFragment(t, stream)
86 }56 }
87 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
88 require.NoError(t, err)58 require.NoError(t, err)
89 var seen strings.Builder59 var seen strings.Builder
90- for range 6 { // the six fragments of this event60+ for range 5 { // the five fragments of this event
91 seen.WriteString(nextFragment(t, stream).data)61 seen.WriteString(nextFragment(t, stream).data)
92 }62 }
93 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
M internal/ports/dto.go +3 -6
@@ -408,16 +408,13 @@ type LoginLink struct {
408408
409409 // InboxCounts are the numbers of open items across every repository, whatever
410410 // the filter.
411-type InboxCounts struct{ MergeRequests, Issues, Pending, Running int }
411+type InboxCounts struct{ MergeRequests, Issues, Pending int }
412412
413413 // InboxOverview is what the owner has to look at across every repository: the
414-// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.
414+// open merge requests and the open issues, most recently updated first. A
415+// section for the items awaiting moderation can be added next to them.
415416 type InboxOverview struct {
416417 Counts InboxCounts
417- // Runs are queued or in progress, newest first, respecting the filter.
418- Runs []domain.CIRun
419- // RunsEnabled distinguishes disabled CI from an empty queue.
420- RunsEnabled bool
421418 // MergeRequests and Issues are those of the filtered repository, or of
422419 // every repository when there is no filter. Counts always counts all.
423420 MergeRequests []domain.MergeRequest
@@ -408,16 +408,13 @@ type LoginLink struct {
408 408
409 // InboxCounts are the numbers of open items across every repository, whatever409 // InboxCounts are the numbers of open items across every repository, whatever
410 // the filter.410 // the filter.
411-type InboxCounts struct{ MergeRequests, Issues, Pending, Running int }411+type InboxCounts struct{ MergeRequests, Issues, Pending int }
412 412
413 // InboxOverview is what the owner has to look at across every repository: the413 // InboxOverview is what the owner has to look at across every repository: the
414-// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.414+// open merge requests and the open issues, most recently updated first. A
415+// section for the items awaiting moderation can be added next to them.
415 type InboxOverview struct {416 type InboxOverview struct {
416 Counts InboxCounts417 Counts InboxCounts
417- // Runs are queued or in progress, newest first, respecting the filter.
418- Runs []domain.CIRun
419- // RunsEnabled distinguishes disabled CI from an empty queue.
420- RunsEnabled bool
421 // MergeRequests and Issues are those of the filtered repository, or of418 // MergeRequests and Issues are those of the filtered repository, or of
422 // every repository when there is no filter. Counts always counts all.419 // every repository when there is no filter. Counts always counts all.
423 MergeRequests []domain.MergeRequest420 MergeRequests []domain.MergeRequest
M internal/usecase/events.go +2 -2
@@ -690,12 +690,12 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
690690 return false
691691 }
692692
693-// isItemEvent reports whether ev changes an inbox item, including CI runs.
693+// isItemEvent reports whether ev is about a merge request or an issue.
694694 func isItemEvent(ev domain.Event) bool {
695695 switch ev.(type) {
696696 case domain.IssueReferenced:
697697 return false // back-reference refreshes do not change the inbox
698- case domain.RunUpdated, domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
698+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
699699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
700700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
701701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
@@ -690,12 +690,12 @@ func isRepoLifecycleEvent(ev domain.Event) bool {
690 return false690 return false
691 }691 }
692 692
693-// isItemEvent reports whether ev changes an inbox item, including CI runs.693+// isItemEvent reports whether ev is about a merge request or an issue.
694 func isItemEvent(ev domain.Event) bool {694 func isItemEvent(ev domain.Event) bool {
695 switch ev.(type) {695 switch ev.(type) {
696 case domain.IssueReferenced:696 case domain.IssueReferenced:
697 return false // back-reference refreshes do not change the inbox697 return false // back-reference refreshes do not change the inbox
698- case domain.RunUpdated, domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,698+ case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,699 domain.MergeRequestAutoMergeEnabled, domain.MergeRequestAutoMergeCancelled,
700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,700 domain.MergeRequestReady, domain.MergeRequestMergeability, domain.CommentAdded,
701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:701 domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved:
M internal/usecase/inbox.go +2 -31
@@ -24,12 +24,11 @@ type Inbox struct {
2424 issues ports.IssueStore
2525 // pending is nil when the public form is not wired.
2626 pending ports.PendingIssueStore
27- ci ports.CIStore
2827 }
2928
3029 var _ ports.InboxService = (*Inbox)(nil)
3130
32-// InboxConfig holds what the inbox needs; Pending and CI are optional.
31+// InboxConfig holds what the inbox needs. Every field is required.
3332 type InboxConfig struct {
3433 Meta ports.RepoMetaStore
3534 MergeRequests ports.MergeRequestStore
@@ -37,12 +36,11 @@ type InboxConfig struct {
3736 // Pending lists the issues awaiting moderation. Optional: without it the
3837 // inbox has no pending section.
3938 Pending ports.PendingIssueStore
40- CI ports.CIStore
4139 }
4240
4341 // NewInbox builds the inbox use case.
4442 func NewInbox(cfg InboxConfig) *Inbox {
45- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
4644 }
4745
4846 // Overview lists what is open, through the indexes of open items. The counts
@@ -128,33 +126,6 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
128126 out.Counts.Pending = len(out.Pending)
129127 }
130128
131- if in.ci != nil {
132- out.RunsEnabled = true
133- runs, err := in.ci.ListOpenRuns(ctx)
134- if err != nil {
135- return out, fmt.Errorf("list open runs: %w", err)
136- }
137- for _, run := range runs {
138- if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139- continue
140- }
141- if err := look(run.Repo); err != nil {
142- return out, err
143- }
144- if !exists[run.Repo] {
145- continue
146- }
147- repos[run.Repo] = true
148- out.Counts.Running++
149- if filter.IsZero() || run.Repo == filter {
150- out.Runs = append(out.Runs, run)
151- }
152- }
153- slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154- return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155- })
156- }
157-
158129 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
159130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
160131 })
@@ -24,12 +24,11 @@ type Inbox struct {
24 issues ports.IssueStore24 issues ports.IssueStore
25 // pending is nil when the public form is not wired.25 // pending is nil when the public form is not wired.
26 pending ports.PendingIssueStore26 pending ports.PendingIssueStore
27- ci ports.CIStore
28 }27 }
29 28
30 var _ ports.InboxService = (*Inbox)(nil)29 var _ ports.InboxService = (*Inbox)(nil)
31 30
32-// InboxConfig holds what the inbox needs; Pending and CI are optional.31+// InboxConfig holds what the inbox needs. Every field is required.
33 type InboxConfig struct {32 type InboxConfig struct {
34 Meta ports.RepoMetaStore33 Meta ports.RepoMetaStore
35 MergeRequests ports.MergeRequestStore34 MergeRequests ports.MergeRequestStore
@@ -37,12 +36,11 @@ type InboxConfig struct {
37 // Pending lists the issues awaiting moderation. Optional: without it the36 // Pending lists the issues awaiting moderation. Optional: without it the
38 // inbox has no pending section.37 // inbox has no pending section.
39 Pending ports.PendingIssueStore38 Pending ports.PendingIssueStore
40- CI ports.CIStore
41 }39 }
42 40
43 // NewInbox builds the inbox use case.41 // NewInbox builds the inbox use case.
44 func NewInbox(cfg InboxConfig) *Inbox {42 func NewInbox(cfg InboxConfig) *Inbox {
45- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}43+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
46 }44 }
47 45
48 // Overview lists what is open, through the indexes of open items. The counts46 // Overview lists what is open, through the indexes of open items. The counts
@@ -128,33 +126,6 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
128 out.Counts.Pending = len(out.Pending)126 out.Counts.Pending = len(out.Pending)
129 }127 }
130 128
131- if in.ci != nil {
132- out.RunsEnabled = true
133- runs, err := in.ci.ListOpenRuns(ctx)
134- if err != nil {
135- return out, fmt.Errorf("list open runs: %w", err)
136- }
137- for _, run := range runs {
138- if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139- continue
140- }
141- if err := look(run.Repo); err != nil {
142- return out, err
143- }
144- if !exists[run.Repo] {
145- continue
146- }
147- repos[run.Repo] = true
148- out.Counts.Running++
149- if filter.IsZero() || run.Repo == filter {
150- out.Runs = append(out.Runs, run)
151- }
152- }
153- slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154- return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155- })
156- }
157-
158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {129 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
160 })131 })
M internal/usecase/inbox_test.go +0 -33
@@ -108,36 +108,3 @@ func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
108108 assert.Nil(t, ov.Pending[0].IPHash)
109109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110110 }
111-
112-func TestInboxRuns(t *testing.T) {
113- t.Parallel()
114- mrs, issues, meta, ci := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewCIStore(t)
115- mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
116- issues.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
117- now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
118- alpha, beta := name(t, "alpha"), name(t, "beta")
119- ci.EXPECT().ListOpenRuns(mock.Anything).Return([]domain.CIRun{
120- {Repo: alpha, ID: 1, Status: domain.CIQueued, CreatedAt: now},
121- {Repo: beta, ID: 2, Status: domain.CIInProgress, CreatedAt: now.Add(time.Hour)},
122- {Repo: alpha, ID: 3, Status: domain.CICompleted, CreatedAt: now.Add(2 * time.Hour)},
123- {Repo: name(t, "gone"), ID: 4, Status: domain.CIQueued, CreatedAt: now},
124- }, nil)
125- for _, n := range []string{"alpha", "beta"} {
126- meta.EXPECT().Get(mock.Anything, name(t, n)).Return(repoWithIssues(t, n, domain.IssuesOff), nil)
127- }
128- meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
129- in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: issues, CI: ci})
130- all, err := in.Overview(t.Context(), domain.Owner(), domain.RepoName{})
131- require.NoError(t, err)
132- assert.True(t, all.RunsEnabled)
133- assert.Equal(t, 2, all.Counts.Running)
134- require.Len(t, all.Runs, 2)
135- assert.Equal(t, int64(2), all.Runs[0].ID)
136- assert.Equal(t, int64(1), all.Runs[1].ID)
137- assert.Equal(t, []domain.RepoName{alpha, beta}, all.Repos)
138- filtered, err := in.Overview(t.Context(), domain.Owner(), alpha)
139- require.NoError(t, err)
140- assert.Equal(t, 2, filtered.Counts.Running)
141- require.Len(t, filtered.Runs, 1)
142- assert.Equal(t, alpha, filtered.Runs[0].Repo)
143-}
@@ -108,36 +108,3 @@ func TestInboxListsPendingIssuesWithoutTheirHash(t *testing.T) {
108 assert.Nil(t, ov.Pending[0].IPHash)108 assert.Nil(t, ov.Pending[0].IPHash)
109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)109 assert.Equal(t, []domain.RepoName{name(t, "on")}, ov.Repos)
110 }110 }
111-
112-func TestInboxRuns(t *testing.T) {
113- t.Parallel()
114- mrs, issues, meta, ci := mocks.NewMergeRequestStore(t), mocks.NewIssueStore(t), mocks.NewRepoMetaStore(t), mocks.NewCIStore(t)
115- mrs.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
116- issues.EXPECT().ListOpenAll(mock.Anything).Return(nil, nil)
117- now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
118- alpha, beta := name(t, "alpha"), name(t, "beta")
119- ci.EXPECT().ListOpenRuns(mock.Anything).Return([]domain.CIRun{
120- {Repo: alpha, ID: 1, Status: domain.CIQueued, CreatedAt: now},
121- {Repo: beta, ID: 2, Status: domain.CIInProgress, CreatedAt: now.Add(time.Hour)},
122- {Repo: alpha, ID: 3, Status: domain.CICompleted, CreatedAt: now.Add(2 * time.Hour)},
123- {Repo: name(t, "gone"), ID: 4, Status: domain.CIQueued, CreatedAt: now},
124- }, nil)
125- for _, n := range []string{"alpha", "beta"} {
126- meta.EXPECT().Get(mock.Anything, name(t, n)).Return(repoWithIssues(t, n, domain.IssuesOff), nil)
127- }
128- meta.EXPECT().Get(mock.Anything, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
129- in := usecase.NewInbox(usecase.InboxConfig{Meta: meta, MergeRequests: mrs, Issues: issues, CI: ci})
130- all, err := in.Overview(t.Context(), domain.Owner(), domain.RepoName{})
131- require.NoError(t, err)
132- assert.True(t, all.RunsEnabled)
133- assert.Equal(t, 2, all.Counts.Running)
134- require.Len(t, all.Runs, 2)
135- assert.Equal(t, int64(2), all.Runs[0].ID)
136- assert.Equal(t, int64(1), all.Runs[1].ID)
137- assert.Equal(t, []domain.RepoName{alpha, beta}, all.Repos)
138- filtered, err := in.Overview(t.Context(), domain.Owner(), alpha)
139- require.NoError(t, err)
140- assert.Equal(t, 2, filtered.Counts.Running)
141- require.Len(t, filtered.Runs, 1)
142- assert.Equal(t, alpha, filtered.Runs[0].Repo)
143-}
M skills/ermite/SKILL.md +0 -13
@@ -85,19 +85,6 @@ docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
8585 - Owner web access: `ermite web login` prints a one-time link (about 60 s, 12 h session).
8686 - If a page or a read looks wrong, set `ERMITE_GIT_READER=cli` and restart: every read then uses the `git` binary.
8787
88-## Releases from CI
89-
90-In a CI job, `GITHUB_API_URL` and `GITHUB_SERVER_URL` point at an address the job can reach (not necessarily the public URL). `softprops/action-gh-release` uses them as is. GoReleaser and other tools with hard-coded GitHub URLs must take them from these variables, never the public URL alone (the job times out):
91-
92-```yaml
93-github_urls:
94- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'
95- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'
96- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'
97-```
98-
99-The workflow needs `permissions: contents: write` and `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. Enable an existing one with `ermite workflow enable <repo> .github/workflows/release.yml`. Full example: `docs/releases.md` in the ermite repository.
100-
10188 ## Output conventions
10289
10390 `--json` prints stable field names, listed in each command's `--help`. Errors are one line on standard error (`ermite: ...`) with exit status 1. `ermite completion bash|zsh|fish` completes repository names.
@@ -85,19 +85,6 @@ docker run -d -p 127.0.0.1:7341:7341 -v ermite-data:/data \
85 - Owner web access: `ermite web login` prints a one-time link (about 60 s, 12 h session).85 - Owner web access: `ermite web login` prints a one-time link (about 60 s, 12 h session).
86 - If a page or a read looks wrong, set `ERMITE_GIT_READER=cli` and restart: every read then uses the `git` binary.86 - If a page or a read looks wrong, set `ERMITE_GIT_READER=cli` and restart: every read then uses the `git` binary.
87 87
88-## Releases from CI
89-
90-In a CI job, `GITHUB_API_URL` and `GITHUB_SERVER_URL` point at an address the job can reach (not necessarily the public URL). `softprops/action-gh-release` uses them as is. GoReleaser and other tools with hard-coded GitHub URLs must take them from these variables, never the public URL alone (the job times out):
91-
92-```yaml
93-github_urls:
94- api: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/'
95- upload: '{{ or (index .Env "GITHUB_API_URL") "https://git.example.org/-/github/api" }}/uploads/'
96- download: '{{ or (index .Env "GITHUB_SERVER_URL") "https://git.example.org" }}/'
97-```
98-
99-The workflow needs `permissions: contents: write` and `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. Enable an existing one with `ermite workflow enable <repo> .github/workflows/release.yml`. Full example: `docs/releases.md` in the ermite repository.
100-
101 ## Output conventions88 ## Output conventions
102 89
103 `--json` prints stable field names, listed in each command's `--help`. Errors are one line on standard error (`ermite: ...`) with exit status 1. `ermite completion bash|zsh|fish` completes repository names.90 `--json` prints stable field names, listed in each command's `--help`. Errors are one line on standard error (`ermite: ...`) with exit status 1. `ermite completion bash|zsh|fish` completes repository names.

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 42

↑↓ moveenter openesc close