detached 302b124

#28 feat(web): list running CI runs in the inbox and open it with Shift+I

state
merged · 801147f · squash
branch
feat/inbox-runs → main
opened
bnema ·
updated

The owner's inbox lists queued and in-progress CI runs in a Running section (after Awaiting moderation), with a running count, the ?repo= filter, and live refresh on run status changes. CI off: no section.

Shift+I opens the inbox from any page for the signed-in owner; lowercase i still opens the issues tab. The help dialog and the inbox button announce it.

Checks: go build, go vet, go test ./internal/..., node --test jstest (199 pass). Wrap-up review (go + js) done; one a11y finding fixed.

CommitSubjectAuthorDateSignatureStatus
302b124feat(web): announce the Shift+I shortcut on the inbox buttonbriceGPG Verified
0f18a24feat(web): list running CI runs in the inbox and open it with Shift+IbriceGPG Verified

Checks 4

52 files · +902 -425
4/4
M docs/issues.md +3 -1
M docs/releases.md +5 -44
M internal/adapters/web/inbox.go +25 -3
M internal/adapters/web/inbox.templ +34 -1
M internal/adapters/web/inbox_templ.go +451 -173
M internal/adapters/web/inbox_test.go +6 -4
A internal/adapters/web/jstest/keys.test.mjs +29 -0
M internal/adapters/web/keys.templ +1 -0
M internal/adapters/web/keys_templ.go +1 -1
M internal/adapters/web/overlays.templ +3 -0
M internal/adapters/web/overlays_templ.go +49 -39
M internal/adapters/web/shell.templ +5 -1
M internal/adapters/web/shell_templ.go +152 -133
M internal/adapters/web/static/js/ermite.js +2 -2
M internal/app/forge.go +1 -1
M internal/app/testdata/web/blob-binary.html +1 -0
M internal/app/testdata/web/blob-code.html +1 -0
M internal/app/testdata/web/blob-empty.html +1 -0
M internal/app/testdata/web/blob-image.html +1 -0
M internal/app/testdata/web/blob-markdown.html +1 -0
M internal/app/testdata/web/blob-svg.html +1 -0
M internal/app/testdata/web/blob-text.html +1 -0
M internal/app/testdata/web/blob-too-large.html +1 -0
M internal/app/testdata/web/blob-trojan.html +1 -0
M internal/app/testdata/web/commit-big.html +1 -0
M internal/app/testdata/web/commit-binary.html +1 -0
M internal/app/testdata/web/commit-merge.html +1 -0
M internal/app/testdata/web/commit-modify-split.html +1 -0
M internal/app/testdata/web/commit-modify.html +1 -0
M internal/app/testdata/web/commit-rename.html +1 -0
M internal/app/testdata/web/commit-root.html +1 -0
M internal/app/testdata/web/commit-trojan.html +1 -0
M internal/app/testdata/web/commits-page2.html +1 -0
M internal/app/testdata/web/commits-path.html +1 -0
M internal/app/testdata/web/commits-root.html +1 -0
M internal/app/testdata/web/commits-small.html +1 -0
M internal/app/testdata/web/empty.html +1 -0
M internal/app/testdata/web/home.html +1 -0
M internal/app/testdata/web/not-found-page.html +1 -0
M internal/app/testdata/web/not-found.html +1 -0
M internal/app/testdata/web/refs.html +1 -0
M internal/app/testdata/web/summary.html +1 -0
M internal/app/testdata/web/tree-nested.html +1 -0
M internal/app/testdata/web/tree-root.html +1 -0
M internal/app/testdata/web/tree-slash-ref.html +1 -0
M internal/app/testdata/web/tree-subdir.html +1 -0
M internal/app/web_inbox_test.go +32 -2
M internal/ports/dto.go +6 -3
M internal/usecase/events.go +2 -2
M internal/usecase/inbox.go +31 -2
M internal/usecase/inbox_test.go +33 -0
@@ -108,3 +108,36 @@ 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,3 +108,36 @@ 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 3

Tags 44

↑↓ moveenter openesc close