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
@@ -471,6 +471,7 @@
471471 k-r from:body -> ermite.tab('r');
472472 k-m from:body -> ermite.tab('m');
473473 k-i from:body -> ermite.tab('i');
474+ k-inbox from:body -> ermite.go('[data-inbox]');
474475 k-next from:body -> ermite.nextNote();
475476 k-split from:body -> ermite.layout('split');
476477 k-unified from:body -> ermite.layout('unified');
@@ -471,6 +471,7 @@
471 k-r from:body -> ermite.tab('r');471 k-r from:body -> ermite.tab('r');
472 k-m from:body -> ermite.tab('m');472 k-m from:body -> ermite.tab('m');
473 k-i from:body -> ermite.tab('i');473 k-i from:body -> ermite.tab('i');
474+ k-inbox from:body -> ermite.go('[data-inbox]');
474 k-next from:body -> ermite.nextNote();475 k-next from:body -> ermite.nextNote();
475 k-split from:body -> ermite.layout('split');476 k-split from:body -> ermite.layout('split');
476 k-unified from:body -> ermite.layout('unified');477 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-merge.html +1 -0
@@ -513,6 +513,7 @@
513513 k-r from:body -> ermite.tab('r');
514514 k-m from:body -> ermite.tab('m');
515515 k-i from:body -> ermite.tab('i');
516+ k-inbox from:body -> ermite.go('[data-inbox]');
516517 k-next from:body -> ermite.nextNote();
517518 k-split from:body -> ermite.layout('split');
518519 k-unified from:body -> ermite.layout('unified');
@@ -513,6 +513,7 @@
513 k-r from:body -> ermite.tab('r');513 k-r from:body -> ermite.tab('r');
514 k-m from:body -> ermite.tab('m');514 k-m from:body -> ermite.tab('m');
515 k-i from:body -> ermite.tab('i');515 k-i from:body -> ermite.tab('i');
516+ k-inbox from:body -> ermite.go('[data-inbox]');
516 k-next from:body -> ermite.nextNote();517 k-next from:body -> ermite.nextNote();
517 k-split from:body -> ermite.layout('split');518 k-split from:body -> ermite.layout('split');
518 k-unified from:body -> ermite.layout('unified');519 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify-split.html +1 -0
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636636 k-r from:body -> ermite.tab('r');
637637 k-m from:body -> ermite.tab('m');
638638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639640 k-next from:body -> ermite.nextNote();
640641 k-split from:body -> ermite.layout('split');
641642 k-unified from:body -> ermite.layout('unified');
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636 k-r from:body -> ermite.tab('r');636 k-r from:body -> ermite.tab('r');
637 k-m from:body -> ermite.tab('m');637 k-m from:body -> ermite.tab('m');
638 k-i from:body -> ermite.tab('i');638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639 k-next from:body -> ermite.nextNote();640 k-next from:body -> ermite.nextNote();
640 k-split from:body -> ermite.layout('split');641 k-split from:body -> ermite.layout('split');
641 k-unified from:body -> ermite.layout('unified');642 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify.html +1 -0
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636636 k-r from:body -> ermite.tab('r');
637637 k-m from:body -> ermite.tab('m');
638638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639640 k-next from:body -> ermite.nextNote();
640641 k-split from:body -> ermite.layout('split');
641642 k-unified from:body -> ermite.layout('unified');
@@ -636,6 +636,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
636 k-r from:body -> ermite.tab('r');636 k-r from:body -> ermite.tab('r');
637 k-m from:body -> ermite.tab('m');637 k-m from:body -> ermite.tab('m');
638 k-i from:body -> ermite.tab('i');638 k-i from:body -> ermite.tab('i');
639+ k-inbox from:body -> ermite.go('[data-inbox]');
639 k-next from:body -> ermite.nextNote();640 k-next from:body -> ermite.nextNote();
640 k-split from:body -> ermite.layout('split');641 k-split from:body -> ermite.layout('split');
641 k-unified from:body -> ermite.layout('unified');642 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-rename.html +1 -0
@@ -540,6 +540,7 @@
540540 k-r from:body -> ermite.tab('r');
541541 k-m from:body -> ermite.tab('m');
542542 k-i from:body -> ermite.tab('i');
543+ k-inbox from:body -> ermite.go('[data-inbox]');
543544 k-next from:body -> ermite.nextNote();
544545 k-split from:body -> ermite.layout('split');
545546 k-unified from:body -> ermite.layout('unified');
@@ -540,6 +540,7 @@
540 k-r from:body -> ermite.tab('r');540 k-r from:body -> ermite.tab('r');
541 k-m from:body -> ermite.tab('m');541 k-m from:body -> ermite.tab('m');
542 k-i from:body -> ermite.tab('i');542 k-i from:body -> ermite.tab('i');
543+ k-inbox from:body -> ermite.go('[data-inbox]');
543 k-next from:body -> ermite.nextNote();544 k-next from:body -> ermite.nextNote();
544 k-split from:body -> ermite.layout('split');545 k-split from:body -> ermite.layout('split');
545 k-unified from:body -> ermite.layout('unified');546 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-root.html +1 -0
@@ -859,6 +859,7 @@
859859 k-r from:body -> ermite.tab('r');
860860 k-m from:body -> ermite.tab('m');
861861 k-i from:body -> ermite.tab('i');
862+ k-inbox from:body -> ermite.go('[data-inbox]');
862863 k-next from:body -> ermite.nextNote();
863864 k-split from:body -> ermite.layout('split');
864865 k-unified from:body -> ermite.layout('unified');
@@ -859,6 +859,7 @@
859 k-r from:body -> ermite.tab('r');859 k-r from:body -> ermite.tab('r');
860 k-m from:body -> ermite.tab('m');860 k-m from:body -> ermite.tab('m');
861 k-i from:body -> ermite.tab('i');861 k-i from:body -> ermite.tab('i');
862+ k-inbox from:body -> ermite.go('[data-inbox]');
862 k-next from:body -> ermite.nextNote();863 k-next from:body -> ermite.nextNote();
863 k-split from:body -> ermite.layout('split');864 k-split from:body -> ermite.layout('split');
864 k-unified from:body -> ermite.layout('unified');865 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-trojan.html +1 -0
@@ -688,6 +688,7 @@
688688 k-r from:body -> ermite.tab('r');
689689 k-m from:body -> ermite.tab('m');
690690 k-i from:body -> ermite.tab('i');
691+ k-inbox from:body -> ermite.go('[data-inbox]');
691692 k-next from:body -> ermite.nextNote();
692693 k-split from:body -> ermite.layout('split');
693694 k-unified from:body -> ermite.layout('unified');
@@ -688,6 +688,7 @@
688 k-r from:body -> ermite.tab('r');688 k-r from:body -> ermite.tab('r');
689 k-m from:body -> ermite.tab('m');689 k-m from:body -> ermite.tab('m');
690 k-i from:body -> ermite.tab('i');690 k-i from:body -> ermite.tab('i');
691+ k-inbox from:body -> ermite.go('[data-inbox]');
691 k-next from:body -> ermite.nextNote();692 k-next from:body -> ermite.nextNote();
692 k-split from:body -> ermite.layout('split');693 k-split from:body -> ermite.layout('split');
693 k-unified from:body -> ermite.layout('unified');694 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-page2.html +1 -0
@@ -441,6 +441,7 @@
441441 k-r from:body -> ermite.tab('r');
442442 k-m from:body -> ermite.tab('m');
443443 k-i from:body -> ermite.tab('i');
444+ k-inbox from:body -> ermite.go('[data-inbox]');
444445 k-next from:body -> ermite.nextNote();
445446 k-split from:body -> ermite.layout('split');
446447 k-unified from:body -> ermite.layout('unified');
@@ -441,6 +441,7 @@
441 k-r from:body -> ermite.tab('r');441 k-r from:body -> ermite.tab('r');
442 k-m from:body -> ermite.tab('m');442 k-m from:body -> ermite.tab('m');
443 k-i from:body -> ermite.tab('i');443 k-i from:body -> ermite.tab('i');
444+ k-inbox from:body -> ermite.go('[data-inbox]');
444 k-next from:body -> ermite.nextNote();445 k-next from:body -> ermite.nextNote();
445 k-split from:body -> ermite.layout('split');446 k-split from:body -> ermite.layout('split');
446 k-unified from:body -> ermite.layout('unified');447 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-path.html +1 -0
@@ -456,6 +456,7 @@
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]');
459460 k-next from:body -> ermite.nextNote();
460461 k-split from:body -> ermite.layout('split');
461462 k-unified from:body -> ermite.layout('unified');
@@ -456,6 +456,7 @@
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]');
459 k-next from:body -> ermite.nextNote();460 k-next from:body -> ermite.nextNote();
460 k-split from:body -> ermite.layout('split');461 k-split from:body -> ermite.layout('split');
461 k-unified from:body -> ermite.layout('unified');462 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-root.html +1 -0
@@ -1246,6 +1246,7 @@
12461246 k-r from:body -> ermite.tab('r');
12471247 k-m from:body -> ermite.tab('m');
12481248 k-i from:body -> ermite.tab('i');
1249+ k-inbox from:body -> ermite.go('[data-inbox]');
12491250 k-next from:body -> ermite.nextNote();
12501251 k-split from:body -> ermite.layout('split');
12511252 k-unified from:body -> ermite.layout('unified');
@@ -1246,6 +1246,7 @@
1246 k-r from:body -> ermite.tab('r');1246 k-r from:body -> ermite.tab('r');
1247 k-m from:body -> ermite.tab('m');1247 k-m from:body -> ermite.tab('m');
1248 k-i from:body -> ermite.tab('i');1248 k-i from:body -> ermite.tab('i');
1249+ k-inbox from:body -> ermite.go('[data-inbox]');
1249 k-next from:body -> ermite.nextNote();1250 k-next from:body -> ermite.nextNote();
1250 k-split from:body -> ermite.layout('split');1251 k-split from:body -> ermite.layout('split');
1251 k-unified from:body -> ermite.layout('unified');1252 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-small.html +1 -0
@@ -611,6 +611,7 @@
611611 k-r from:body -> ermite.tab('r');
612612 k-m from:body -> ermite.tab('m');
613613 k-i from:body -> ermite.tab('i');
614+ k-inbox from:body -> ermite.go('[data-inbox]');
614615 k-next from:body -> ermite.nextNote();
615616 k-split from:body -> ermite.layout('split');
616617 k-unified from:body -> ermite.layout('unified');
@@ -611,6 +611,7 @@
611 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
612 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613 k-i from:body -> ermite.tab('i');613 k-i from:body -> ermite.tab('i');
614+ k-inbox from:body -> ermite.go('[data-inbox]');
614 k-next from:body -> ermite.nextNote();615 k-next from:body -> ermite.nextNote();
615 k-split from:body -> ermite.layout('split');616 k-split from:body -> ermite.layout('split');
616 k-unified from:body -> ermite.layout('unified');617 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/empty.html +1 -0
@@ -284,6 +284,7 @@
284284 k-r from:body -> ermite.tab('r');
285285 k-m from:body -> ermite.tab('m');
286286 k-i from:body -> ermite.tab('i');
287+ k-inbox from:body -> ermite.go('[data-inbox]');
287288 k-next from:body -> ermite.nextNote();
288289 k-split from:body -> ermite.layout('split');
289290 k-unified from:body -> ermite.layout('unified');
@@ -284,6 +284,7 @@
284 k-r from:body -> ermite.tab('r');284 k-r from:body -> ermite.tab('r');
285 k-m from:body -> ermite.tab('m');285 k-m from:body -> ermite.tab('m');
286 k-i from:body -> ermite.tab('i');286 k-i from:body -> ermite.tab('i');
287+ k-inbox from:body -> ermite.go('[data-inbox]');
287 k-next from:body -> ermite.nextNote();288 k-next from:body -> ermite.nextNote();
288 k-split from:body -> ermite.layout('split');289 k-split from:body -> ermite.layout('split');
289 k-unified from:body -> ermite.layout('unified');290 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/home.html +1 -0
@@ -394,6 +394,7 @@
394394 k-r from:body -> ermite.tab('r');
395395 k-m from:body -> ermite.tab('m');
396396 k-i from:body -> ermite.tab('i');
397+ k-inbox from:body -> ermite.go('[data-inbox]');
397398 k-next from:body -> ermite.nextNote();
398399 k-split from:body -> ermite.layout('split');
399400 k-unified from:body -> ermite.layout('unified');
@@ -394,6 +394,7 @@
394 k-r from:body -> ermite.tab('r');394 k-r from:body -> ermite.tab('r');
395 k-m from:body -> ermite.tab('m');395 k-m from:body -> ermite.tab('m');
396 k-i from:body -> ermite.tab('i');396 k-i from:body -> ermite.tab('i');
397+ k-inbox from:body -> ermite.go('[data-inbox]');
397 k-next from:body -> ermite.nextNote();398 k-next from:body -> ermite.nextNote();
398 k-split from:body -> ermite.layout('split');399 k-split from:body -> ermite.layout('split');
399 k-unified from:body -> ermite.layout('unified');400 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found-page.html +1 -0
@@ -267,6 +267,7 @@
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]');
270271 k-next from:body -> ermite.nextNote();
271272 k-split from:body -> ermite.layout('split');
272273 k-unified from:body -> ermite.layout('unified');
@@ -267,6 +267,7 @@
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]');
270 k-next from:body -> ermite.nextNote();271 k-next from:body -> ermite.nextNote();
271 k-split from:body -> ermite.layout('split');272 k-split from:body -> ermite.layout('split');
272 k-unified from:body -> ermite.layout('unified');273 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +1 -0
@@ -267,6 +267,7 @@
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]');
270271 k-next from:body -> ermite.nextNote();
271272 k-split from:body -> ermite.layout('split');
272273 k-unified from:body -> ermite.layout('unified');
@@ -267,6 +267,7 @@
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]');
270 k-next from:body -> ermite.nextNote();271 k-next from:body -> ermite.nextNote();
271 k-split from:body -> ermite.layout('split');272 k-split from:body -> ermite.layout('split');
272 k-unified from:body -> ermite.layout('unified');273 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +1 -0
@@ -503,6 +503,7 @@
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]');
506507 k-next from:body -> ermite.nextNote();
507508 k-split from:body -> ermite.layout('split');
508509 k-unified from:body -> ermite.layout('unified');
@@ -503,6 +503,7 @@
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]');
506 k-next from:body -> ermite.nextNote();507 k-next from:body -> ermite.nextNote();
507 k-split from:body -> ermite.layout('split');508 k-split from:body -> ermite.layout('split');
508 k-unified from:body -> ermite.layout('unified');509 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +1 -0
@@ -529,6 +529,7 @@
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]');
532533 k-next from:body -> ermite.nextNote();
533534 k-split from:body -> ermite.layout('split');
534535 k-unified from:body -> ermite.layout('unified');
@@ -529,6 +529,7 @@
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]');
532 k-next from:body -> ermite.nextNote();533 k-next from:body -> ermite.nextNote();
533 k-split from:body -> ermite.layout('split');534 k-split from:body -> ermite.layout('split');
534 k-unified from:body -> ermite.layout('unified');535 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +1 -0
@@ -443,6 +443,7 @@
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]');
446447 k-next from:body -> ermite.nextNote();
447448 k-split from:body -> ermite.layout('split');
448449 k-unified from:body -> ermite.layout('unified');
@@ -443,6 +443,7 @@
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]');
446 k-next from:body -> ermite.nextNote();447 k-next from:body -> ermite.nextNote();
447 k-split from:body -> ermite.layout('split');448 k-split from:body -> ermite.layout('split');
448 k-unified from:body -> ermite.layout('unified');449 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +1 -0
@@ -664,6 +664,7 @@
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]');
667668 k-next from:body -> ermite.nextNote();
668669 k-split from:body -> ermite.layout('split');
669670 k-unified from:body -> ermite.layout('unified');
@@ -664,6 +664,7 @@
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]');
667 k-next from:body -> ermite.nextNote();668 k-next from:body -> ermite.nextNote();
668 k-split from:body -> ermite.layout('split');669 k-split from:body -> ermite.layout('split');
669 k-unified from:body -> ermite.layout('unified');670 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +1 -0
@@ -440,6 +440,7 @@
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]');
443444 k-next from:body -> ermite.nextNote();
444445 k-split from:body -> ermite.layout('split');
445446 k-unified from:body -> ermite.layout('unified');
@@ -440,6 +440,7 @@
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]');
443 k-next from:body -> ermite.nextNote();444 k-next from:body -> ermite.nextNote();
444 k-split from:body -> ermite.layout('split');445 k-split from:body -> ermite.layout('split');
445 k-unified from:body -> ermite.layout('unified');446 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +1 -0
@@ -456,6 +456,7 @@
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]');
459460 k-next from:body -> ermite.nextNote();
460461 k-split from:body -> ermite.layout('split');
461462 k-unified from:body -> ermite.layout('unified');
@@ -456,6 +456,7 @@
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]');
459 k-next from:body -> ermite.nextNote();460 k-next from:body -> ermite.nextNote();
460 k-split from:body -> ermite.layout('split');461 k-split from:body -> ermite.layout('split');
461 k-unified from:body -> ermite.layout('unified');462 k-unified from:body -> ermite.layout('unified');
M internal/app/web_inbox_test.go +32 -2
@@ -8,6 +8,7 @@ import (
88 "net/http"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/require"
@@ -42,22 +43,51 @@ func TestWebInboxEndToEnd(t *testing.T) {
4243 assert.Contains(t, empty.body, "no open merge requests.")
4344 assert.Contains(t, empty.body, "no open issues.")
4445
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+
4556 // The owner stream sees both repositories.
4657 stream := ownerStream(t, r.harness, token)
4758
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+
4878 root := domain.Root()
4979 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
5080 require.NoError(t, err)
5181 ev := nextFragment(t, stream)
5282 assert.Equal(t, "inbox", ev.name)
5383 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 4 { // the other fragments of this event
84+ for range 5 { // the other fragments of this event
5585 nextFragment(t, stream)
5686 }
5787 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
5888 require.NoError(t, err)
5989 var seen strings.Builder
60- for range 5 { // the five fragments of this event
90+ for range 6 { // the six fragments of this event
6191 seen.WriteString(nextFragment(t, stream).data)
6292 }
6393 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
@@ -8,6 +8,7 @@ import (
8 "net/http"8 "net/http"
9 "strings"9 "strings"
10 "testing"10 "testing"
11+ "time"
11 12
12 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -42,22 +43,51 @@ func TestWebInboxEndToEnd(t *testing.T) {
42 assert.Contains(t, empty.body, "no open merge requests.")43 assert.Contains(t, empty.body, "no open merge requests.")
43 assert.Contains(t, empty.body, "no open issues.")44 assert.Contains(t, empty.body, "no open issues.")
44 45
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+
45 // The owner stream sees both repositories.56 // The owner stream sees both repositories.
46 stream := ownerStream(t, r.harness, token)57 stream := ownerStream(t, r.harness, token)
47 58
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+
48 root := domain.Root()78 root := domain.Root()
49 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})79 _, err = r.forge.MergeRequests.Create(r.ctx, root, r.name("proj"), ports.CreateMergeRequestParams{Title: "add search", Source: "feat/search", Target: "main"})
50 require.NoError(t, err)80 require.NoError(t, err)
51 ev := nextFragment(t, stream)81 ev := nextFragment(t, stream)
52 assert.Equal(t, "inbox", ev.name)82 assert.Equal(t, "inbox", ev.name)
53 assert.Contains(t, ev.data, `id="inbox-counts"`)83 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 4 { // the other fragments of this event84+ for range 5 { // the other fragments of this event
55 nextFragment(t, stream)85 nextFragment(t, stream)
56 }86 }
57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})87 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58 require.NoError(t, err)88 require.NoError(t, err)
59 var seen strings.Builder89 var seen strings.Builder
60- for range 5 { // the five fragments of this event90+ for range 6 { // the six fragments of this event
61 seen.WriteString(nextFragment(t, stream).data)91 seen.WriteString(nextFragment(t, stream).data)
62 }92 }
63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")93 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
M internal/ports/dto.go +6 -3
@@ -408,13 +408,16 @@ 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 int }
411+type InboxCounts struct{ MergeRequests, Issues, Pending, Running int }
412412
413413 // InboxOverview is what the owner has to look at across every repository: the
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.
414+// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.
416415 type InboxOverview struct {
417416 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
418421 // MergeRequests and Issues are those of the filtered repository, or of
419422 // every repository when there is no filter. Counts always counts all.
420423 MergeRequests []domain.MergeRequest
@@ -408,13 +408,16 @@ 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 int }411+type InboxCounts struct{ MergeRequests, Issues, Pending, Running 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 the open issues, most recently updated first. A414+// open merge requests and issues, ongoing CI runs, and issues awaiting moderation.
415-// section for the items awaiting moderation can be added next to them.
416 type InboxOverview struct {415 type InboxOverview struct {
417 Counts InboxCounts416 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
418 // MergeRequests and Issues are those of the filtered repository, or of421 // MergeRequests and Issues are those of the filtered repository, or of
419 // every repository when there is no filter. Counts always counts all.422 // every repository when there is no filter. Counts always counts all.
420 MergeRequests []domain.MergeRequest423 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 is about a merge request or an issue.
693+// isItemEvent reports whether ev changes an inbox item, including CI runs.
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.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,
698+ case domain.RunUpdated, 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 is about a merge request or an issue.693+// isItemEvent reports whether ev changes an inbox item, including CI runs.
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.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed,698+ case domain.RunUpdated, 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 +31 -2
@@ -24,11 +24,12 @@ 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
2728 }
2829
2930 var _ ports.InboxService = (*Inbox)(nil)
3031
31-// InboxConfig holds what the inbox needs. Every field is required.
32+// InboxConfig holds what the inbox needs; Pending and CI are optional.
3233 type InboxConfig struct {
3334 Meta ports.RepoMetaStore
3435 MergeRequests ports.MergeRequestStore
@@ -36,11 +37,12 @@ type InboxConfig struct {
3637 // Pending lists the issues awaiting moderation. Optional: without it the
3738 // inbox has no pending section.
3839 Pending ports.PendingIssueStore
40+ CI ports.CIStore
3941 }
4042
4143 // NewInbox builds the inbox use case.
4244 func NewInbox(cfg InboxConfig) *Inbox {
43- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
45+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
4446 }
4547
4648 // Overview lists what is open, through the indexes of open items. The counts
@@ -126,6 +128,33 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
126128 out.Counts.Pending = len(out.Pending)
127129 }
128130
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+
129158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131160 })
@@ -24,11 +24,12 @@ 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
27 }28 }
28 29
29 var _ ports.InboxService = (*Inbox)(nil)30 var _ ports.InboxService = (*Inbox)(nil)
30 31
31-// InboxConfig holds what the inbox needs. Every field is required.32+// InboxConfig holds what the inbox needs; Pending and CI are optional.
32 type InboxConfig struct {33 type InboxConfig struct {
33 Meta ports.RepoMetaStore34 Meta ports.RepoMetaStore
34 MergeRequests ports.MergeRequestStore35 MergeRequests ports.MergeRequestStore
@@ -36,11 +37,12 @@ type InboxConfig struct {
36 // Pending lists the issues awaiting moderation. Optional: without it the37 // Pending lists the issues awaiting moderation. Optional: without it the
37 // inbox has no pending section.38 // inbox has no pending section.
38 Pending ports.PendingIssueStore39 Pending ports.PendingIssueStore
40+ CI ports.CIStore
39 }41 }
40 42
41 // NewInbox builds the inbox use case.43 // NewInbox builds the inbox use case.
42 func NewInbox(cfg InboxConfig) *Inbox {44 func NewInbox(cfg InboxConfig) *Inbox {
43- return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}45+ return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
44 }46 }
45 47
46 // Overview lists what is open, through the indexes of open items. The counts48 // Overview lists what is open, through the indexes of open items. The counts
@@ -126,6 +128,33 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain
126 out.Counts.Pending = len(out.Pending)128 out.Counts.Pending = len(out.Pending)
127 }129 }
128 130
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+
129 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {158 slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)159 return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131 })160 })
M internal/usecase/inbox_test.go +33 -0
M skills/ermite/SKILL.md +0 -13

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 44

↑↓ moveenter openesc close