Compare commits
638
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 | ||
|
|
02a472f841 | ||
|
|
c5e6e17fc1 | ||
|
|
8a12524595 | ||
|
|
cbe4017bdc | ||
|
|
fcc9ead33e | ||
|
|
0d29b29bef | ||
|
|
dce9ae04c9 | ||
|
|
9ec88179c3 | ||
|
|
93890e72de | ||
|
|
8f0df3d393 | ||
|
|
fc79e4523d | ||
|
|
741d545199 | ||
|
|
9320016b50 | ||
|
|
b069379df0 | ||
|
|
8387c57ceb | ||
|
|
72842ecbcd | ||
|
|
c503994be1 | ||
|
|
5e6328a8d0 | ||
|
|
4209834d6d | ||
|
|
46888d2876 | ||
|
|
7d7abefb0b | ||
|
|
2796808759 | ||
|
|
a2f83b0955 | ||
|
|
6b56188d30 | ||
|
|
467c8c665d | ||
|
|
1b311c9cd4 | ||
|
|
71d06347fd | ||
|
|
1de6d73e75 | ||
|
|
46c75f0b22 | ||
|
|
5cdb3f7b3e | ||
|
|
15d197327a | ||
|
|
df8385b325 | ||
|
|
47c6412a12 | ||
|
|
6cac778acd | ||
|
|
a8a93b2f3b | ||
|
|
ef37a66fd7 | ||
|
|
52e2c26304 | ||
|
|
3817d2c67b | ||
|
|
ad6c1edf80 | ||
|
|
d2b2c7bada | ||
|
|
9ba03613d4 | ||
|
|
1132075318 | ||
|
|
16b1d8670f | ||
|
|
e5c5c8ec31 | ||
|
|
87e0d74414 | ||
|
|
f031a37e8d | ||
|
|
057bec7dc2 | ||
|
|
654ae8714e | ||
|
|
a0dd044995 | ||
|
|
c93a682dfb | ||
|
|
5675ca5d41 | ||
|
|
0767ccaa9d | ||
|
|
c6eabb5fc7 | ||
|
|
77fab85a66 | ||
|
|
dcf26c896c | ||
|
|
fba1f4238d | ||
|
|
9e301924b4 | ||
|
|
8edd542379 | ||
|
|
0fbe047e03 | ||
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 | ||
|
|
f04a547e19 | ||
|
|
ea3712ca3e | ||
|
|
7421a8f62d | ||
|
|
982c2a1fb7 | ||
|
|
3e7d6f7111 | ||
|
|
32394fcd8f | ||
|
|
ea3194cdca | ||
|
|
85043b4af6 | ||
|
|
dc202bde11 | ||
|
|
c4ba78c8cb | ||
|
|
e35d7dea35 | ||
|
|
b81d695585 | ||
|
|
75da76c6f5 | ||
|
|
f78d69fba5 | ||
|
|
8ce64d2aa7 | ||
|
|
48f75cff05 | ||
|
|
58260d8a26 | ||
|
|
54ff44462e | ||
|
|
a87a5a4a90 | ||
|
|
c91194c283 | ||
|
|
5806d47b61 | ||
|
|
95f24a4e90 | ||
|
|
c016261b1a | ||
|
|
bbc3e10525 | ||
|
|
9737300d55 | ||
|
|
a08a51af02 | ||
|
|
528d721cd3 | ||
|
|
a91788276c | ||
|
|
28a2c9faf1 | ||
|
|
b8befc2041 | ||
|
|
d23c0c894b | ||
|
|
7948d2e353 | ||
|
|
1079ec6561 | ||
|
|
8d142c808b | ||
|
|
776ae70019 | ||
|
|
285fdff869 | ||
|
|
89d153d6a5 | ||
|
|
fcb0575e38 | ||
|
|
14ba1bcf14 | ||
|
|
a68639668c | ||
|
|
1f0e9fb59d | ||
|
|
065275b4ef | ||
|
|
90f1c48565 | ||
|
|
ae07dd185c | ||
|
|
f43f776479 | ||
|
|
48e4344f28 | ||
|
|
269916486c | ||
|
|
07235d3870 | ||
|
|
8c32a9dd81 | ||
|
|
9edd3f6441 | ||
|
|
4faf0447be | ||
|
|
8ea46974ff | ||
|
|
075a277265 | ||
|
|
a7ec4437d9 | ||
|
|
bf2cf37151 | ||
|
|
fb58ba30b2 | ||
|
|
17823352f2 | ||
|
|
14a3e1ee8d | ||
|
|
8dc155e49a | ||
|
|
7aa25f1d8c | ||
|
|
f023414929 | ||
|
|
96e7b35419 | ||
|
|
4c13e13275 | ||
|
|
ebe19f2281 | ||
|
|
798790ce1a | ||
|
|
9629643a63 | ||
|
|
9f3855d779 | ||
|
|
c99dd3e3be | ||
|
|
7e173640ed | ||
|
|
87f63370f6 | ||
|
|
614ffae188 | ||
|
|
aaa9aef78b | ||
|
|
10a8b2738b | ||
|
|
379f2b8203 | ||
|
|
53bf89bd47 | ||
|
|
697f72da09 | ||
|
|
c170240057 | ||
|
|
3043ccc846 | ||
|
|
c5ddb1275b | ||
|
|
1fb26e0a86 | ||
|
|
be7bfcaa5a | ||
|
|
6a3c6c7a9e | ||
|
|
cab230e3a2 | ||
|
|
80ea81ad73 | ||
|
|
8cd2459468 | ||
|
|
7ee10619a4 | ||
|
|
a8da5ce949 | ||
|
|
8e357a097b | ||
|
|
43f5833f7f | ||
|
|
27fff15da4 | ||
|
|
a2b78710f2 | ||
|
|
e9271f5aab | ||
|
|
257bc0110e | ||
|
|
5e0683cd4a | ||
|
|
c94fc5308f | ||
|
|
0304f5ab2c | ||
|
|
50d51ed159 | ||
|
|
8e74f022eb | ||
|
|
92b0fd56ca | ||
|
|
7ce352553d | ||
|
|
f4a4ed84c3 | ||
|
|
b21888fd7a | ||
|
|
320d6348ca | ||
|
|
7f8ee771dc | ||
|
|
f446e10670 | ||
|
|
79309196b8 | ||
|
|
cdf6ecdcb8 | ||
|
|
797fd0d544 | ||
|
|
a13e25f6c9 | ||
|
|
91e5b1e0c9 | ||
|
|
1f05521094 | ||
|
|
59c263fc59 | ||
|
|
4c64cc4c3e | ||
|
|
6e90e3fa9e | ||
|
|
83702e75b0 | ||
|
|
446281674b | ||
|
|
9c5b100d6a | ||
|
|
163bfad30a | ||
|
|
23c8e57a8e | ||
|
|
350bfc067d | ||
|
|
98aecd8255 | ||
|
|
7d4ff1f095 | ||
|
|
3605c42651 | ||
|
|
d7e2813700 | ||
|
|
fb34b5ffda | ||
|
|
820da7130d | ||
|
|
3bad8a0198 | ||
|
|
8bc5f623ac | ||
|
|
175817e6d8 | ||
|
|
fff7bccf08 | ||
|
|
ee36b8824a | ||
|
|
2c8a1225e0 | ||
|
|
29ceedfc01 | ||
|
|
11a56b21a1 | ||
|
|
3e16dd00d1 | ||
|
|
31f6097afc | ||
|
|
beaf5e0587 | ||
|
|
0a96dfb173 | ||
|
|
7cadb36b41 | ||
|
|
b1890a29ff | ||
|
|
100d38cc5c | ||
|
|
e5a352b418 | ||
|
|
8907263760 | ||
|
|
fdcd0d31ab | ||
|
|
5bef6291e8 | ||
|
|
9724358c89 | ||
|
|
7555cefccb | ||
|
|
04621223fd | ||
|
|
e5016d8b86 | ||
|
|
b455dc6286 | ||
|
|
338b6ebbd0 | ||
|
|
f18c59c7b3 | ||
|
|
e9cab92aff | ||
|
|
877dc475dd | ||
|
|
28e70f6415 | ||
|
|
2a6f2ab12d | ||
|
|
c8809a0728 | ||
|
|
2717f41957 | ||
|
|
b26b57d616 | ||
|
|
1052ce2c84 | ||
|
|
4f0e6544b3 | ||
|
|
91c59a2953 | ||
|
|
6d1fa3c068 | ||
|
|
21a386b94f | ||
|
|
7e5b966f77 | ||
|
|
78d044f1af | ||
|
|
814c5726ec | ||
|
|
95a14c6de5 | ||
|
|
37aa2dd090 | ||
|
|
6565165295 | ||
|
|
4749d15d85 | ||
|
|
16604d697b | ||
|
|
59b4d5e6bf | ||
|
|
83af187c73 | ||
|
|
d4896e10be | ||
|
|
81e1af7f16 | ||
|
|
b1b3f758e4 | ||
|
|
ced644b275 | ||
|
|
131848cc43 | ||
|
|
62a0841eb5 | ||
|
|
cc63fc6499 | ||
|
|
9dede4bf2a | ||
|
|
b667963968 | ||
|
|
31b0e79aa1 | ||
|
|
8206856d94 | ||
|
|
7ec3330175 | ||
|
|
ec339d242d | ||
|
|
d161cf41c0 | ||
|
|
535658185a | ||
|
|
8b7c56161c | ||
|
|
2b2e2cceb4 | ||
|
|
de596f397f | ||
|
|
af86fc1e98 | ||
|
|
5bf491c725 | ||
|
|
11341ec5d9 | ||
|
|
eefb760169 | ||
|
|
3f9dd49766 | ||
|
|
72453b76db | ||
|
|
7639a42d7b | ||
|
|
0b992543f1 | ||
|
|
9904f17e3d | ||
|
|
0dd3932d14 | ||
|
|
8e6ee96ede | ||
|
|
161977cb67 | ||
|
|
e3c07c2dfb | ||
|
|
a610708e36 | ||
|
|
f8c5c2b058 | ||
|
|
72c470b652 | ||
|
|
b214a49686 | ||
|
|
a9508984e2 | ||
|
|
20d5176713 | ||
|
|
c96ed51b0d | ||
|
|
cf2e49300a | ||
|
|
5e49f27142 | ||
|
|
0280077e45 | ||
|
|
f781410e80 | ||
|
|
0311f85d96 | ||
|
|
5a6ef62c4c | ||
|
|
461c796227 | ||
|
|
6309ed43cd | ||
|
|
a0aa3c0a22 | ||
|
|
60bc7cd686 | ||
|
|
79c8f1626b | ||
|
|
cd9f40a1c4 | ||
|
|
0a3fb180ed | ||
|
|
74d1435ca7 | ||
|
|
8989e93c2c | ||
|
|
7b3b7dadb9 | ||
|
|
05f9e06a10 | ||
|
|
e0de45ea90 | ||
|
|
3224389441 | ||
|
|
63723e7fa3 | ||
|
|
740e91a615 | ||
|
|
0363f23cef | ||
|
|
2cbeba30fd | ||
|
|
43595de52f | ||
|
|
c451cd0bcb | ||
|
|
ad27db6846 | ||
|
|
791082bae2 | ||
|
|
d3ed0cca0c | ||
|
|
ccb595a081 | ||
|
|
7b1c9a608c | ||
|
|
854d040e88 | ||
|
|
95baa20eeb | ||
|
|
0cf88d7d7a | ||
|
|
969d5567d4 | ||
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 | ||
|
|
c31129f1cc | ||
|
|
4afe4dd3b9 | ||
|
|
985472eb06 | ||
|
|
8b36455c21 | ||
|
|
3ba1fe76f0 | ||
|
|
321474e8cf | ||
|
|
166d14dd35 | ||
|
|
7a62f7ead7 | ||
|
|
461f90c8ff | ||
|
|
9ea590a65c | ||
|
|
33a6d1bb6d | ||
|
|
b9ae9c6c83 | ||
|
|
45ceedb679 | ||
|
|
2e2a7821ae | ||
|
|
33427d2407 | ||
|
|
f0b0b703ff | ||
|
|
055a9dbb2a | ||
|
|
1f7fd093fd | ||
|
|
ff0e47cbe7 | ||
|
|
c2da869c47 | ||
|
|
8ada75ec33 | ||
|
|
6dea5fd1ef | ||
|
|
bf4ce3adad | ||
|
|
34664edb8b | ||
|
|
f92794f571 | ||
|
|
edbe2d76dd | ||
|
|
cbdddcddbe | ||
|
|
de6c9c249a | ||
|
|
19122c69e6 | ||
|
|
2253c40577 | ||
|
|
5c2c0383df | ||
|
|
4e13f5482b | ||
|
|
51278bc86f | ||
|
|
a919242b8f | ||
|
|
56a9a0cba6 | ||
|
|
27fc74ced6 | ||
|
|
f686e2e71e | ||
|
|
8f7a836d28 | ||
|
|
5894eafb3f | ||
|
|
639ce911be | ||
|
|
475acbb92f | ||
|
|
c0fc4eddd0 | ||
|
|
1bf759dc76 | ||
|
|
9441191c18 | ||
|
|
76bea83537 | ||
|
|
7775c3e1f6 | ||
|
|
98249ad9ed | ||
|
|
901691a93b | ||
|
|
6dfc02158d | ||
|
|
454022ee89 | ||
|
|
4bc2881a4b | ||
|
|
3c743a2b71 | ||
|
|
7ec3980713 | ||
|
|
d940e0b37b | ||
|
|
4610afd489 | ||
|
|
020106f001 | ||
|
|
3384e56cb8 | ||
|
|
229d112297 | ||
|
|
34029e624f | ||
|
|
0ff6b5c8fd | ||
|
|
391e8964c4 | ||
|
|
990f0f04d6 | ||
|
|
a995e3b45f | ||
|
|
ac3d1f32d1 | ||
|
|
e11e55149c | ||
|
|
ae6cde29c2 | ||
|
|
0539b0a035 | ||
|
|
a2ba624dc2 | ||
|
|
e78ceb1bf7 | ||
|
|
ac7a716e5c | ||
|
|
fa4ad28961 | ||
|
|
3726360baa | ||
|
|
5d326c30a1 | ||
|
|
2cb92811c9 | ||
|
|
9e68382bd7 | ||
|
|
b4dc785dd8 | ||
|
|
a9051d843c | ||
|
|
4969cadd82 | ||
|
|
bf96560831 | ||
|
|
7342b2e6de | ||
|
|
671c88d287 | ||
|
|
503d6f4988 | ||
|
|
eb2c84a6c2 | ||
|
|
fb6b42bcaf | ||
|
|
41eaf51953 | ||
|
|
c9c5939f0a | ||
|
|
34af4ce903 | ||
|
|
7034ec55ed | ||
|
|
d46d296e09 | ||
|
|
21e0a49d3c | ||
|
|
160753caf8 | ||
|
|
0ea264a942 | ||
|
|
d6c7a495ed | ||
|
|
dc18004e02 | ||
|
|
b5858046ee | ||
|
|
b5b6b4d0a9 | ||
|
|
6143568b95 | ||
|
|
9a152c435e | ||
|
|
80a1fa60bd | ||
|
|
8ba4d48549 | ||
|
|
c0b42b71cf | ||
|
|
d026bbd400 | ||
|
|
9189d13db9 | ||
|
|
9786724678 | ||
|
|
6b99d4f171 | ||
|
|
7d633e67e8 | ||
|
|
bd5e66b4e5 | ||
|
|
add45365ab | ||
|
|
6138b1f24e | ||
|
|
40c66df143 | ||
|
|
7356530318 | ||
|
|
9f6476a5b4 | ||
|
|
de8227f4f4 | ||
|
|
ccf5f69400 | ||
|
|
207df76b69 | ||
|
|
43c7a85eec | ||
|
|
d0b8c3dc0d | ||
|
|
6e6e63e553 | ||
|
|
54029d2668 | ||
|
|
1f57b75d5d | ||
|
|
fefbe7e084 | ||
|
|
31a07e4a46 | ||
|
|
b458dbaa78 | ||
|
|
8c4e729c4a | ||
|
|
80417abd5b | ||
|
|
37d55e9b20 | ||
|
|
94cbde3cf9 | ||
|
|
fedddb246d | ||
|
|
e28472b268 | ||
|
|
3b85b4f35d | ||
|
|
997cb7dcd7 | ||
|
|
04bb6839ae | ||
|
|
1fdf6f417d | ||
|
|
cc38b3d0f6 | ||
|
|
b11ec6a732 | ||
|
|
e58808f2d4 | ||
|
|
e0543b2007 | ||
|
|
0ac1277479 | ||
|
|
6185f6a7ff | ||
|
|
98d6a29fa0 | ||
|
|
3decdb4cac | ||
|
|
0fa3e675dc | ||
|
|
3210b8be6d | ||
|
|
87becb08ad | ||
|
|
334fe77e84 | ||
|
|
c8de28ca0c | ||
|
|
741d768fc3 | ||
|
|
acd2d82d10 | ||
|
|
65d1f9f194 | ||
|
|
c74c63d29c | ||
|
|
94aad3e76e | ||
|
|
00a956b1fb | ||
|
|
7bdfe62495 | ||
|
|
1b10a5a8a8 | ||
|
|
a7afd85634 | ||
|
|
39357263d0 | ||
|
|
e40c75b360 | ||
|
|
fdb39e43ad | ||
|
|
61168dbfd4 | ||
|
|
65a09b5c66 | ||
|
|
8e0389153c | ||
|
|
c3e8ead47b | ||
|
|
5f6dd265a5 | ||
|
|
a4beca2d9e | ||
|
|
5f3f0176f1 | ||
|
|
b3c76dcd69 | ||
|
|
540584a417 | ||
|
|
4f6ba0302d | ||
|
|
d7d6170c3e | ||
|
|
51ac802d77 | ||
|
|
e0bc7271fa | ||
|
|
ddd87e9051 | ||
|
|
8b71de70bd | ||
|
|
2bd8426db8 | ||
|
|
9289434ad2 | ||
|
|
a73b3bc773 | ||
|
|
d155f0081f | ||
|
|
bad7d524f0 | ||
|
|
ea7f3cf098 | ||
|
|
d66c1b48ef | ||
|
|
8df41e2fe7 | ||
|
|
51bd9d41f2 | ||
|
|
7110b11c15 | ||
|
|
3ed3a2be58 | ||
|
|
bb00d3cf35 | ||
|
|
1b2a3c83af | ||
|
|
a579b95f47 | ||
|
|
6bf5512b47 | ||
|
|
1c2dbd2168 | ||
|
|
a25b547d20 | ||
|
|
4499a7f947 | ||
|
|
f655a5115a | ||
|
|
bca27f0330 | ||
|
|
19dfc93b85 | ||
|
|
ce26f54618 | ||
|
|
ea909ef222 | ||
|
|
3824ff180a | ||
|
|
174dac05b9 | ||
|
|
ea1f4ad837 | ||
|
|
d09ceb8650 | ||
|
|
c0cc6ec46e | ||
|
|
aaff109ede | ||
|
|
2fe9b6cc3d | ||
|
|
a08ce31858 | ||
|
|
092234af38 | ||
|
|
c63b23865c | ||
|
|
e04d6753ac | ||
|
|
b5545e380e | ||
|
|
2fecccfbde | ||
|
|
9e697dc63f | ||
|
|
5e826bcc7e | ||
|
|
2be29b684a | ||
|
|
09b2898d1c | ||
|
|
a5f63a6fa6 | ||
|
|
4cc00d2e48 | ||
|
|
f1448e6b58 | ||
|
|
2243b9453c | ||
|
|
fe834a37a4 | ||
|
|
46070d03f6 | ||
|
|
7651e5b090 | ||
|
|
de20406a45 | ||
|
|
2072de337e | ||
|
|
ecd4b55a26 | ||
|
|
c639b09aaf | ||
|
|
5406ee2297 | ||
|
|
937df5d20b | ||
|
|
c0eabbf865 | ||
|
|
2d8a659ebe | ||
|
|
3ff173ca89 | ||
|
|
0bc6e01eb5 | ||
|
|
4da3b198d0 | ||
|
|
87058d72e6 | ||
|
|
e96ff42dff | ||
|
|
b6c7ec83e4 | ||
|
|
b4f984e331 | ||
|
|
b4b13a7db1 | ||
|
|
1947f878e9 | ||
|
|
6f54d5764b | ||
|
|
9293dde1af | ||
|
|
1f997cc504 | ||
|
|
f9e11fca6a | ||
|
|
390ad0d4d2 | ||
|
|
b673911e36 | ||
|
|
484c0d4aeb | ||
|
|
5ce29fbe7d | ||
|
|
625e65e7e4 | ||
|
|
29a786cea3 | ||
|
|
4870b58c5b | ||
|
|
5fe0b28747 | ||
|
|
58376f6d45 | ||
|
|
4b7405aa65 | ||
|
|
b6390d3298 | ||
|
|
e5f001f2b0 | ||
|
|
745c3779c2 | ||
|
|
35e46f81f1 | ||
|
|
bb394be8b2 | ||
|
|
8c889e3ffc | ||
|
|
1c0cacb2b8 | ||
|
|
30a014b619 | ||
|
|
9b81dce8aa | ||
|
|
7f5e671f8d | ||
|
|
83de2a7499 | ||
|
|
62ebabb132 | ||
|
|
f7073e1393 | ||
|
|
5b864c028e | ||
|
|
8ee7dbf79e | ||
|
|
db0354a6e5 | ||
|
|
a86de5cbf4 | ||
|
|
953f59fb19 | ||
|
|
36d7fb9ce8 | ||
|
|
955343a428 | ||
|
|
b51542363c | ||
|
|
54638d2686 | ||
|
|
db9e51e683 | ||
|
|
cafc062b9f | ||
|
|
ea7bd15721 | ||
|
|
3807e7afd1 | ||
|
|
0dc5c2c831 | ||
|
|
efb3ae3400 | ||
|
|
6e900e31e3 | ||
|
|
678005ece9 | ||
|
|
f34fc8b5f5 | ||
|
|
d75a55f3f7 | ||
|
|
30f92d18a2 | ||
|
|
e2a4b60143 | ||
|
|
331eb99c1e | ||
|
|
bd0ae619db | ||
|
|
173af9f698 | ||
|
|
3bf635fb89 | ||
|
|
d8937f0096 | ||
|
|
c4a9c76781 |
No files matched your search
+61
-5
@@ -17,13 +17,69 @@ jobs:
|
||||
- run: pip install -r requirements.txt
|
||||
- run: python3 -m pytest tests/ -q
|
||||
|
||||
build-frontend:
|
||||
build-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Pack frontend
|
||||
run: tar -czf frontend.tar.gz frontend
|
||||
- name: Upload artifact
|
||||
- name: Build deterministic release bundle
|
||||
run: |
|
||||
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
|
||||
python3 scripts/build_release.py \
|
||||
--root . \
|
||||
--output-dir dist \
|
||||
--commit "$GITHUB_SHA" \
|
||||
--source-date-epoch "$SOURCE_DATE_EPOCH"
|
||||
- name: Upload tested release bundle
|
||||
uses: actions/upload-artifact@v3
|
||||
with: { name: frontend, path: frontend.tar.gz }
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist/
|
||||
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, build-release]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download tested release bundle
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: release-bundle
|
||||
path: dist
|
||||
- name: Verify and publish release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
(cd dist && sha256sum -c ./*.sha256)
|
||||
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json > /tmp/release-response.json
|
||||
RELEASE_ID="$(python3 -c 'import json; print(json.load(open("/tmp/release-response.json"))["id"])')"
|
||||
|
||||
for ASSET in dist/*; do
|
||||
NAME="$(basename "$ASSET")"
|
||||
ENCODED_NAME="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL/$RELEASE_ID/assets?name=$ENCODED_NAME" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$ASSET"
|
||||
done
|
||||
|
||||
printf '{"draft":false,"prerelease":true}\n' > /tmp/publish.json
|
||||
curl --fail-with-body -sS -X PATCH "$RELEASE_URL/$RELEASE_ID" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/publish.json
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-candidate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create Gitea tag and release candidate
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v0.1.0-rc.${{ github.run_number }}"
|
||||
TARGET="${{ github.sha }}"
|
||||
TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags"
|
||||
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" > /tmp/tag.json
|
||||
curl --fail-with-body -sS -X POST "$TAG_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/tag.json
|
||||
|
||||
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \
|
||||
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json
|
||||
curl --fail-with-body -sS -X POST "$RELEASE_URL" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @/tmp/release.json
|
||||
@@ -2,3 +2,4 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.release-engine/
|
||||
.stackchain-state/
|
||||
@@ -14,25 +14,485 @@ python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
||||
read-scoped access token, then start the API and bundled frontend:
|
||||
token that can read dashboard data, update the authenticated user's notification
|
||||
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
||||
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
||||
inspect/comment on assigned pull
|
||||
requests, merge assigned pull requests, and submit pull-request reviews.
|
||||
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
||||
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
||||
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
||||
at least two characters after `@` offers repository-scoped teammate suggestions;
|
||||
touch or keyboard selection inserts the login without leaving the draft. Mention
|
||||
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
||||
creation and assignment permission. A fresh capture never silently targets the first repository:
|
||||
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
||||
authenticated repository search. Search results include only visible repository identities, stale
|
||||
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
||||
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
||||
repository and meaningful title are selected,
|
||||
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
||||
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
||||
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
||||
when search is unavailable. The sheet can also optionally select an open
|
||||
repository milestone and due date; the dashboard validates both and sends them with
|
||||
self-assignment in the single create request, so planned work appears in its release
|
||||
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
||||
account-bound title/description captures without selecting a repository or entering the
|
||||
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
||||
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
||||
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
||||
the private content. Issue capture and authored mobile actions (issue
|
||||
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
||||
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
||||
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
||||
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
||||
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
||||
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
||||
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
||||
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
||||
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||
as unknown rather than unblocked. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After a reload or installed-app
|
||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
||||
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
||||
to durable account-bound delivery, then removes the item only from Today and opens the next
|
||||
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
||||
for the current unread-update conversation. It deliberately leaves the notification unread;
|
||||
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
||||
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
||||
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
||||
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
||||
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
||||
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
||||
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
||||
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
||||
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
||||
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
||||
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
||||
intent. The server retains no more than 4,096 operation receipts per account and removes
|
||||
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
||||
changing a newer Today or Later plan. After a healthy, fully paginated
|
||||
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
||||
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
||||
leave the plan unchanged, and offline retirements replay after reconnect. When
|
||||
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
||||
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
||||
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
||||
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
||||
and pull-request diffs remain online-only. Saved unread-update conversations remain
|
||||
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
|
||||
deduplicated acknowledgement to the durable background delivery system, removes the
|
||||
update from the local queue immediately, and opens the next saved conversation. A cold
|
||||
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
||||
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
|
||||
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
|
||||
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
|
||||
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
|
||||
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
|
||||
not changed by this session boundary. Installed-app
|
||||
navigations are also deadline-bounded: after four seconds without a network response,
|
||||
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
||||
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
||||
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
|
||||
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
|
||||
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
|
||||
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
|
||||
refresh still run immediately; a successful response resets transport backoff.
|
||||
Closed-app
|
||||
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
||||
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
||||
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
|
||||
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
|
||||
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
||||
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
||||
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
||||
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
||||
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
||||
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
||||
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
||||
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
|
||||
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
|
||||
five seconds to finish and persist their ledger result before it closes the Gitea transport.
|
||||
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
|
||||
shutdown budget; when that deadline expires, remaining operations are cancelled and their
|
||||
pending reservations continue to fail closed rather than being retried automatically.
|
||||
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
||||
If contention occurs after the upstream mutation, the dashboard fails closed with
|
||||
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
|
||||
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
|
||||
`201` response is confirmed. Closing an assigned issue, native Comment,
|
||||
Approve, and Request changes reviews, and assigned-PR merge require repository
|
||||
write permission. Native Comment, Approve, and Request changes reviews support
|
||||
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
||||
comment path and submits the summary, decision, and inline comments in one review
|
||||
request. On phone-width review sheets, changed lines wrap inside a stable old/new
|
||||
line-number gutter by default so long source lines remain readable and commentable
|
||||
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
|
||||
horizontal inspection, and an explicit choice persists on the device across files,
|
||||
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
|
||||
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
|
||||
state, and mergeability immediately before every merge.
|
||||
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
||||
access is intentionally disabled. Authentication defaults to fail-closed
|
||||
`operator` mode. It requires two independent secrets of at least 24 characters;
|
||||
missing, short, reused, or invalid configuration leaves `/healthz` available but
|
||||
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
|
||||
the browser receives only a short-lived signed session and CSRF proof. Generate
|
||||
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
||||
them at runtime—never commit them:
|
||||
|
||||
```bash
|
||||
export GITEA_URL='https://forge.example.com'
|
||||
export GITEA_TOKEN='<read-scoped-token>'
|
||||
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Optional compatibility overrides; defaults derive from the required public origin.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to eight hours.
|
||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
||||
# Background polling and queued delivery do not. Defaults to 15 minutes.
|
||||
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
||||
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
||||
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
||||
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
||||
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
||||
# Public passkey ceremonies share the same window and durable source ledger.
|
||||
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
|
||||
# Bound live one-time challenges even if anonymous clients rotate addresses.
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
||||
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
||||
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
||||
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
||||
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
||||
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
||||
# The feature stays disabled unless all three values are present. Privacy-safe update
|
||||
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
||||
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
|
||||
# three individual alerts followed by one private digest that opens Updates. A later
|
||||
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
|
||||
# that thread's updated_at revision; unchanged and older snapshots remain silent.
|
||||
# Browser push services must resolve exclusively to public IP addresses. Stackchain
|
||||
# validates endpoints at enrollment and again before delivery, rejects redirects,
|
||||
# and removes legacy subscriptions that resolve to private or reserved networks.
|
||||
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
||||
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
||||
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
||||
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
||||
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
||||
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
||||
# within each device; one slow device does not delay healthy devices behind it.
|
||||
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
||||
# Unread updates and deadline reminders run on independent, fixed-cadence
|
||||
# workers, so a slow channel cannot delay the other or add drift to its ticks.
|
||||
# On each phone, Device Setup can enable deadline reminders and choose any local
|
||||
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
|
||||
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
||||
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
||||
# Maximum individual alerts per device and poll before one digest covers the rest.
|
||||
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
|
||||
export STACKCHAIN_PUSH_LEASE_SECONDS=60
|
||||
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
||||
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8000/` for the dashboard. To verify the backend and its
|
||||
Gitea connection directly, request
|
||||
`http://127.0.0.1:8000/api/v1/context`; a successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Never commit the token or place
|
||||
it in a tracked configuration file.
|
||||
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
||||
persisted across workers and restarts. Public passkey option issuance has a separate
|
||||
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
||||
source and globally in the session registry. Once either sign-in budget is exhausted,
|
||||
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
||||
failure state. Expired source records and challenges are pruned, and both ledgers are
|
||||
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
||||
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
|
||||
networks you operate. Without that setting, a reverse proxy is safely treated as one
|
||||
shared source.
|
||||
|
||||
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
||||
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
||||
requests under `/api/v1/` are capped at 64 KiB. Both declared and streamed bodies
|
||||
are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response;
|
||||
validation errors expose field locations and messages but never echo submitted
|
||||
values or validation context. Preserve these limits at the reverse proxy or enforce
|
||||
equal or tighter upstream limits.
|
||||
|
||||
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
||||
kept in the SQLite session registry. Keep that registry on persistent, writable
|
||||
storage shared by all dashboard workers. Operators name a device at sign-in and can
|
||||
open **Active devices** to review creation/expiry times, identify the current device,
|
||||
and revoke one remote session without interrupting other trusted devices. The API
|
||||
exposes only independent management IDs and bounded labels—never cookie values,
|
||||
session hashes, CSRF proofs, or source addresses. The registry also stores each
|
||||
session's last explicit activity. Existing two-column registries are migrated in
|
||||
place, their live sessions remain valid, and their idle clock starts at migration.
|
||||
|
||||
The same sheet includes **Security activity**, a reverse-chronological journal of
|
||||
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
||||
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
||||
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
||||
action targets. It never stores access tokens, cookies, session/CSRF values,
|
||||
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
||||
request bodies, or comment content. Keep its database on the same class of persistent,
|
||||
writable storage as the session registry. Before passkey credential creation, session
|
||||
revocation, or issue closure, the journal durably reserves a pending event; if that
|
||||
reservation fails, the consequential action does not begin. A successful action
|
||||
remains truthfully reported even if its event cannot immediately be finalized, and
|
||||
the activity sheet marks that durable record as **Outcome confirmation pending**.
|
||||
|
||||
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
||||
WebAuthn credential with required user verification. That device can then sign in
|
||||
and authorize high-impact actions with its biometric/PIN gesture. The access token
|
||||
remains the recovery fallback for browsers without WebAuthn or devices without an
|
||||
enrolled credential. Registration and authentication challenges are exact-purpose,
|
||||
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
|
||||
including those whose original session has expired or signed out, using only its
|
||||
bounded label, enrollment time, and active/current status. Removing one requires a
|
||||
single-use fresh authorization bound to that exact management ID. A remote active
|
||||
session linked to the credential is revoked atomically; unrelated credentials and
|
||||
sessions remain valid. Removing the current device's passkey keeps its current
|
||||
session active, so the sheet warns that the recovery token will be required after
|
||||
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
||||
normally keeps the passkey available for the next sign-in. Passkey assertion counters
|
||||
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
|
||||
fail before a session or authorization grant is issued, while counterless authenticators
|
||||
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
|
||||
only the device label and attempted action; repeated alerts should prompt removal and
|
||||
re-enrollment of that passkey.
|
||||
|
||||
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
||||
remote device, or signing out every device—require a passkey assertion or the
|
||||
operator access token again.
|
||||
The server issues a random 90-second grant bound to the active session, exact action,
|
||||
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
||||
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
||||
Gitea or session state is changed. The browser preserves the pending request and
|
||||
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
||||
|
||||
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
||||
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
|
||||
polling or background delivery continued. The dashboard locks and background outbox
|
||||
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
|
||||
the device; signing in resumes the existing account-bound work. If an active session
|
||||
expires, the first authenticated API rejection replaces the
|
||||
dashboard with sign-in and explains that private drafts remain on the device; signing
|
||||
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
||||
state. A selectively or globally revoked device instead receives a bounded revocation
|
||||
reason: on its next server contact, Stackchain clears its owned local/session storage,
|
||||
private outbox database, and dashboard caches before enabling sign-in. Revocation
|
||||
blocks server access immediately, but no web application can erase a device that
|
||||
remains offline forever. **Sign out & clear this device** revokes only the current
|
||||
session before clearing browser state, so a copied cookie cannot be replayed
|
||||
afterward; other signed-in devices remain active. **Sign out all devices** is a
|
||||
separately confirmed lost-device safety action that atomically revokes every existing
|
||||
operator session before clearing the current browser and returning to sign-in.
|
||||
Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
||||
|
||||
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
||||
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
||||
Every response also defines the browser execution boundary with a Content Security
|
||||
Policy that allows scripts only from the dashboard origin, denies framing and
|
||||
plugins, and blocks unused browser capabilities. Keep these response headers when
|
||||
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
||||
is assembled in source order into one content-addressed JavaScript response. Dashboard
|
||||
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
||||
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
||||
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
||||
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
||||
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
||||
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
||||
the injected value and restarting the service; changing the signing secret expires
|
||||
all sessions immediately. The signing key is never derived from the access token.
|
||||
|
||||
For direct, unproxied local development only, set
|
||||
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
|
||||
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
|
||||
clients; do not use it behind a reverse proxy, whose loopback connection would hide
|
||||
the browser's peer address. To verify the backend and its Gitea connection in
|
||||
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
|
||||
successful response is JSON containing
|
||||
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
||||
to search commands plus issues and pull requests across every repository visible to
|
||||
the configured Gitea token. Remote search starts after two characters, is debounced,
|
||||
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
||||
result opens a mobile-safe preview without discarding the search query. Open
|
||||
unassigned issues can be claimed in place and handed into My Work after Gitea
|
||||
confirms the assignment. A closed issue can be reopened, self-assigned, added to
|
||||
Today, and resumed through the same capacity-guarded flow; pull requests remain
|
||||
read-only with a safe canonical Gitea link. The bounded APIs are available at
|
||||
`GET /api/v1/search?q=<query>&limit=<1-25>`,
|
||||
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
|
||||
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
|
||||
or place it in a tracked configuration file.
|
||||
|
||||
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
||||
API process is running and does not contact Gitea. GET `/readyz` is the
|
||||
readiness check: it validates the configured Gitea credentials and returns
|
||||
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
||||
|
||||
The application-lifetime Gitea transport bounds aggregate upstream traffic and
|
||||
coalesces identical concurrent GETs without caching completed responses. By
|
||||
default, at most eight requests run at once, with one slot reserved for authored
|
||||
mutations so polling and detail-read bursts cannot starve comments, reviews, or
|
||||
other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
|
||||
503 responses. Tune these limits with `GITEA_MAX_CONCURRENCY` (minimum `2`) and
|
||||
`GITEA_ADMISSION_TIMEOUT_SECONDS` (minimum `0.001`); keep the admission timeout
|
||||
below the route deadlines. Streaming diff reads share the same read capacity,
|
||||
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
||||
|
||||
Live snapshots expose independent `context`, `events`, and `notifications`
|
||||
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
|
||||
atomically through a private SQLite store shared by all application workers. An expiring
|
||||
refresh lease ensures only one worker loads currently due sections; other workers serve the
|
||||
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
|
||||
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
|
||||
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
|
||||
worker-shared writable storage. The database and directory are restricted to the service
|
||||
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
|
||||
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
||||
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
||||
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
||||
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
|
||||
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
|
||||
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
|
||||
within its remaining request budget. Releasing
|
||||
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
||||
by the next authoritative scan. Each bounded opaque revision token includes the
|
||||
store generation, so a token from a different deployment or before replacement of the store
|
||||
cannot suppress different content. The browser sends its known tokens on later polls, so
|
||||
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
|
||||
retry metadata. The client retains omitted data and only rebuilds or persists the sections
|
||||
that changed. Malformed or oversized tokens are rejected before any upstream work.
|
||||
Confirmed notification acknowledgements update this worker's live view immediately, then
|
||||
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
|
||||
all successful notification IDs in one SQLite transaction and advance the notification
|
||||
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
|
||||
API keeps the confirmed result and process-local filtering rather than falsely reporting the
|
||||
upstream write as failed; a later shared refresh reconciles the cache.
|
||||
|
||||
## Offline mobile shell
|
||||
|
||||
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
||||
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
|
||||
filter, preserves the selected release lane, shows the current draft count, respects
|
||||
the device safe area, and moves out of the way while a full-screen task is open.
|
||||
Desktop layout is unchanged.
|
||||
|
||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||
Deferred items leave normal and Attention queues without marking notifications read
|
||||
or changing any Gitea issue or pull request. They automatically return to their
|
||||
existing priority position at the wake time. **Start now** atomically admits a deferred
|
||||
item to Today, removes its Later record, and opens that exact item in a resumable Today
|
||||
session; full or unavailable Today storage leaves the deferral intact for retry. Items
|
||||
already in Today are opened without duplication. **Bring back now** still restores an
|
||||
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||
and replay after reconnect. Each queued edit carries the account revision it was based
|
||||
on; if another device has since changed the same item, Stackchain keeps the newer
|
||||
account plan and reports the conflict instead of silently overwriting it. Unrelated
|
||||
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
|
||||
default durable store at `.stackchain-state/later.sqlite3`.
|
||||
|
||||
After one successful online load, the installed dashboard precaches a versioned,
|
||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
||||
it is available so local issue and review drafts remain reachable. Authentication
|
||||
and other client-error responses are never hidden. A visible, accessible offline
|
||||
or server-outage notice distinguishes cached mode from live Gitea data, and the
|
||||
dashboard keeps polling until it can restore its live snapshot automatically.
|
||||
|
||||
Users can explicitly enable **Keep My Work available offline**. Each healthy live
|
||||
refresh then stores a seven-day, versioned snapshot containing only the signed-in
|
||||
user identity and queue-card metadata for issues, pull requests, unread updates,
|
||||
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
|
||||
newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
||||
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
||||
per file. A previously opened unread update is retained by notification
|
||||
identity with allowlisted subject context and its newest 20 conversation messages.
|
||||
The snapshot and each account-bound detail are committed as separate IndexedDB records,
|
||||
so large review previews do not consume the small synchronous localStorage quota or require
|
||||
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
|
||||
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
|
||||
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
|
||||
patches, and complete API responses are excluded.
|
||||
A cold offline launch labels the saved time. Cached Today details open in the existing
|
||||
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
||||
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
||||
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
|
||||
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
|
||||
**Queue review for reconnect** durably stores that complete SHA-bound review with one
|
||||
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
|
||||
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
|
||||
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
|
||||
queued operation and its matching SHA-scoped draft and progress.
|
||||
Cached unread updates use the same phone conversation sheet and replies enter the
|
||||
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
||||
Cards without a saved detail explain that reconnection is required. **Clear offline
|
||||
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
|
||||
IndexedDB records; seven-day expiry removes stale records automatically.
|
||||
|
||||
API responses and mutations are never cached by the service worker. New issue captures,
|
||||
issue comments, pull-request comments, and unread-update replies use bounded local
|
||||
outboxes when connectivity or a retryable server failure prevents delivery. Issue
|
||||
captures and authored messages are also mirrored into account-bound IndexedDB lanes
|
||||
and registered with Background Sync, so a supporting installed browser can deliver
|
||||
new issues, issue comments, pull-request comments, unread-update replies, and completed
|
||||
pull-request reviews after
|
||||
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
||||
an atomic delivery claim with the foreground path, and preserves the original
|
||||
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
||||
work finishes**; permission is requested only from that user gesture and the choice
|
||||
is stored for the confirmed account in the private background outbox database.
|
||||
Successful deliveries produce privacy-safe receipts that open the created issue or
|
||||
source conversation, while permanent validation failures open Drafts for recovery.
|
||||
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
||||
claim without creating an Attention receipt, stops that drain, and keeps the original
|
||||
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
||||
to resume queued delivery automatically.
|
||||
Notification text never includes issue titles, comment bodies, or validation details.
|
||||
The preference is off by default, unsupported or denied browsers retain foreground
|
||||
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
||||
Browsers without
|
||||
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
||||
queued and needs-attention messages with explicit send/discard controls; reopening the
|
||||
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
||||
Foreground delivery flushes messages sequentially with their original idempotency keys.
|
||||
Tabs coordinate each queue through the browser lock manager (with an expiring local
|
||||
lease fallback), so an installed PWA and a browser tab cannot submit the same operation
|
||||
concurrently. Queue changes are broadcast to other tabs and refresh Drafts without
|
||||
polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such
|
||||
as reviews, merges, closures, labels, milestones, and assignments are never queued.
|
||||
Service-worker upgrades are atomic and remove only older
|
||||
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
||||
|
||||
Run the test suite with:
|
||||
|
||||
```bash
|
||||
@@ -67,6 +527,10 @@ python3 -m src.release_engine \
|
||||
--repo stackchain/stackchain-dashboard \
|
||||
--agent timmy \
|
||||
--issue 19 \
|
||||
--agent-timeout 1800 \
|
||||
--test-timeout 900 \
|
||||
--termination-grace 5 \
|
||||
--agent-env AGENT_CONFIG_HOME \
|
||||
--test-command 'python3 -m pytest tests/ -q'
|
||||
```
|
||||
|
||||
@@ -75,6 +539,42 @@ claim, reruns the test command, pushes the branch, and opens a PR containing
|
||||
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
||||
do not run without `--issue` when processing a preselected ticket.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Full behavior and
|
||||
safety gates are documented in
|
||||
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
||||
not through a shell. Issue context is available to the agent only through
|
||||
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
||||
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
||||
text. Shell substitutions, redirects, and pipelines are intentionally not
|
||||
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
||||
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
||||
|
||||
Every child process receives an explicit least-privilege environment rather
|
||||
than inheriting the worker's credentials. Coding agents receive a minimal
|
||||
runtime environment plus the five `RELEASE_*` context values above. Tests and
|
||||
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
|
||||
unrelated parent secrets remain in the release-engine process. If an agent
|
||||
runtime needs another non-secret setting, allow it explicitly with a repeated
|
||||
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
|
||||
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
|
||||
before the issue is claimed. Secret-bearing values must not be allowlisted.
|
||||
|
||||
Agent and test deadlines are independent. On expiry, the engine terminates the
|
||||
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
|
||||
configured grace period, and records `agent_timed_out` or `tests_timed_out`
|
||||
without advancing the release. The equivalent environment variables are
|
||||
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
|
||||
`RELEASE_TERMINATION_GRACE`; every value must be positive.
|
||||
|
||||
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
|
||||
record the repository, issue branch, and exact commit SHA. A restart resumes at
|
||||
tests, push, or PR creation only after validating that identity and a clean
|
||||
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
|
||||
are serialized by an OS-backed, non-blocking lease at
|
||||
`.release-engine/run.lock` under the canonical repository path. If an hourly
|
||||
run overlaps a manual or slow prior run, the contender exits with `release run
|
||||
already active` and owner diagnostics before Gitea discovery or any Git/agent
|
||||
work. The kernel releases ownership when the process exits, so never delete a
|
||||
lock file to recover from a crash; stale file metadata cannot retain the lease.
|
||||
Checkpoint replacement uses writer-unique, flushed temporary files and an
|
||||
atomic rename, preventing concurrent writers from colliding or exposing partial
|
||||
JSON. Full behavior and safety gates are documented in
|
||||
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
@@ -12,15 +12,29 @@ Convert one eligible Gitea issue into a tested, traceable pull request without d
|
||||
- Repository key (`owner/repo`).
|
||||
- Agent username.
|
||||
- Optional explicit issue number; otherwise deterministic queue selection.
|
||||
- Coding-agent command template.
|
||||
- Test command.
|
||||
- Coding-agent command, parsed into an argument vector.
|
||||
- Test command, parsed into an argument vector.
|
||||
- Positive, independent coding-agent and test deadlines plus a termination grace period.
|
||||
- Optional explicit allowlist of non-secret parent environment variables needed by the coding-agent runtime.
|
||||
- Local repository path and durable state-file path.
|
||||
|
||||
## State machine
|
||||
|
||||
`discovered → claimed → agent_complete → tests_passed → pushed → pr_opened`
|
||||
|
||||
Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
Terminal failure states include `claim_failed`, `agent_failed`, `agent_timed_out`, `tests_failed`, `tests_timed_out`, and `push_failed`. A rerun resumes from persisted state and never opens a duplicate PR.
|
||||
|
||||
Successful checkpoints are schema-versioned and bind the repository, issue,
|
||||
deterministic branch, and exact commit SHA. On restart, the engine:
|
||||
|
||||
- resumes `agent_complete` at tests;
|
||||
- resumes `tests_passed` at push; and
|
||||
- resumes `pushed` at PR creation after verifying the remote branch SHA.
|
||||
|
||||
Before skipping any stage, it checks out the recorded branch, verifies its
|
||||
local `HEAD`, and requires a clean worktree. Missing identity, an unsupported
|
||||
schema, or any repository/issue/branch/SHA mismatch fails closed instead of
|
||||
repeating or advancing delivery.
|
||||
|
||||
## Queue selection
|
||||
|
||||
@@ -30,6 +44,19 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
4. Otherwise sort by priority label (`P0`, `P1`, `P2`, unlabeled) then issue number.
|
||||
5. Select exactly one issue per invocation.
|
||||
|
||||
## Single-active-run lease
|
||||
|
||||
- Before issue discovery, every invocation acquires a non-blocking advisory lock
|
||||
at `.release-engine/run.lock` under the canonical repository path.
|
||||
- The lease is held through the final checkpoint and pull-request request. A
|
||||
contended invocation fails immediately with `release run already active`
|
||||
before contacting Gitea or running Git, agent, or test commands.
|
||||
- The lock file records bounded owner PID and acquisition-time diagnostics.
|
||||
These are informational only: kernel lock ownership is authoritative, so a
|
||||
killed or crashed process releases the lease automatically and stale metadata
|
||||
cannot block the next run.
|
||||
- Different canonical repository paths use independent leases.
|
||||
|
||||
## Claiming
|
||||
|
||||
- PATCH the issue with the configured assignee.
|
||||
@@ -39,9 +66,18 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
## Execution
|
||||
|
||||
- Branch format: `<agent>/<issue>-<slug>`.
|
||||
- Coding command receives issue number, title, body, repo, and branch through template fields and environment variables.
|
||||
- Coding and test commands are parsed with POSIX argument quoting and executed directly without a shell.
|
||||
- Issue number, title, body, repo, and branch are supplied to the coding agent only through `RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`, `RELEASE_REPO`, and `RELEASE_BRANCH` environment variables. Gitea-controlled content is never interpolated into executable syntax.
|
||||
- Child processes never inherit the complete parent environment. Coding receives a minimal runtime environment, explicitly allowlisted non-secret agent variables, and the five issue-context values. Tests and Git receive only the minimal runtime environment; `GITEA_TOKEN` remains parent-process-only.
|
||||
- Agent variables are allowlisted with repeated `--agent-env NAME` options or comma-separated `RELEASE_AGENT_ENV`. `GITEA_TOKEN`, `RELEASE_*`, and malformed names fail validation before claim.
|
||||
- Shell operators, substitutions, and pipelines are not interpreted. Operators that are intentionally required must live in a separately reviewed wrapper script configured as the command.
|
||||
- Empty or malformed commands abort before the issue claim.
|
||||
- Non-zero coding-agent exit blocks tests and PR creation.
|
||||
- Tests run using the configured command; stdout/stderr and exit code become evidence.
|
||||
- Coding-agent and test commands each run in a new process session. Their deadlines default to 1800 and 900 seconds, respectively.
|
||||
- On expiry, the engine sends `SIGTERM` to the command's entire process group, waits for the termination grace period (5 seconds by default), then sends `SIGKILL` to the group and reaps the command. This prevents descendants from keeping the worker wedged.
|
||||
- Deadlines are configured with `--agent-timeout`, `--test-timeout`, and `--termination-grace`, or `RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and `RELEASE_TERMINATION_GRACE`. Non-positive values abort before issue discovery or claim.
|
||||
- Timeout evidence is bounded in durable state. An agent timeout blocks tests; a test timeout blocks push and PR creation.
|
||||
|
||||
## PR and release gate
|
||||
|
||||
@@ -55,6 +91,12 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
|
||||
- `--dry-run` performs discovery and planning only: no claim, git mutation, agent command, push, or PR.
|
||||
- State is written atomically after each successful transition.
|
||||
- Each checkpoint write uses a writer-unique temporary file, flushes file data,
|
||||
atomically replaces the checkpoint, and flushes its parent directory. A
|
||||
failed or concurrent writer therefore cannot collide on a shared temp path or
|
||||
expose partial JSON.
|
||||
- Resumable state carries repository and commit identity; successful stages are
|
||||
skipped only after local (and, after push, remote) Git verification.
|
||||
- One invocation handles at most one issue.
|
||||
- Missing token, dirty worktree, failed claim verification, failed tests, or missing evidence blocks PR creation.
|
||||
|
||||
@@ -68,3 +110,13 @@ Terminal failure states are `claim_failed`, `agent_failed`, `tests_failed`, and
|
||||
6. Passing tests produce a linked PR request with evidence.
|
||||
7. Existing state/PR prevents duplicate work.
|
||||
8. Dry-run against live Gitea returns a plan and performs no mutation.
|
||||
9. A timed-out command terminates its descendants and returns within its deadline plus grace period.
|
||||
10. Agent and test timeouts persist distinct terminal states and block every later delivery stage.
|
||||
11. Non-positive deadline configuration aborts before issue claim.
|
||||
12. Restarts from `agent_complete`, `tests_passed`, and `pushed` skip only the
|
||||
completed stages and open exactly one linked PR.
|
||||
13. Dirty or identity-mismatched checkpoints stop before push or PR creation.
|
||||
14. Parent credentials are absent from coding-agent, test, and Git subprocesses while explicit agent variables and issue context reach only the coding stage.
|
||||
15. Reserved or malformed agent environment allowlist entries abort before claim.
|
||||
16. An overlapping invocation fails before Gitea discovery or local command execution and reports bounded owner diagnostics.
|
||||
17. Concurrent checkpoint-save stress completes without temp-file collisions or partial state.
|
||||
@@ -0,0 +1,83 @@
|
||||
function createAgendaReplan({ now = () => new Date(), update }) {
|
||||
let items = [];
|
||||
let index = 0;
|
||||
let pending = false;
|
||||
let active = false;
|
||||
|
||||
const dayKey = date => [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const dueDay = value => {
|
||||
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
|
||||
return match ? match[1] : '';
|
||||
};
|
||||
const current = () => active ? items[index] || null : null;
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
index,
|
||||
total: items.length,
|
||||
current: current()?.key || null,
|
||||
pending,
|
||||
});
|
||||
const advance = () => {
|
||||
index += 1;
|
||||
if (index >= items.length) active = false;
|
||||
return { ok:true, done:!active };
|
||||
};
|
||||
const change = async dueDate => {
|
||||
const item = current();
|
||||
if (!item) return { ok:false, error:'No overdue deadline selected.' };
|
||||
if (pending) return { ok:false, error:'Deadline update already in progress.' };
|
||||
pending = true;
|
||||
try {
|
||||
await update(item, dueDate);
|
||||
return advance();
|
||||
} catch (error) {
|
||||
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(overdue) {
|
||||
items = (overdue || []).slice().sort((left, right) =>
|
||||
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
index = 0;
|
||||
pending = false;
|
||||
active = items.length > 0;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
current,
|
||||
keep() {
|
||||
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
|
||||
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
|
||||
return Promise.resolve(advance());
|
||||
},
|
||||
tomorrow() {
|
||||
const date = new Date(now());
|
||||
date.setDate(date.getDate() + 1);
|
||||
return change(dayKey(date) + 'T23:59:59Z');
|
||||
},
|
||||
choose(value) {
|
||||
const chosen = String(value || '');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
|
||||
return Promise.resolve({ ok:false, error:'Choose a future date.' });
|
||||
}
|
||||
return change(chosen + 'T23:59:59Z');
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
pending = false;
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
|
||||
let request = null;
|
||||
|
||||
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
|
||||
if (request) return request;
|
||||
if (!available()) {
|
||||
announce('Today is full—remove an item before assigning this issue.');
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
request = Promise.resolve()
|
||||
.then(() => alreadyOwned ? item : claim(item))
|
||||
.then(confirmed => {
|
||||
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
|
||||
if (outcome === 'queued') {
|
||||
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
|
||||
'Assigned and queued in Today. Keep finding work when ready.');
|
||||
return outcome;
|
||||
}
|
||||
if (outcome === 'started') {
|
||||
announce(alreadyOwned ? 'Added to Today and ready to work.' :
|
||||
'Assigned, added to Today, and ready to work.');
|
||||
return outcome;
|
||||
}
|
||||
if (destination === 'queue') {
|
||||
if (outcome === 'sync-unavailable') {
|
||||
announce(alreadyOwned ?
|
||||
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
|
||||
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not be queued. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
|
||||
}
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not start. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
}
|
||||
recover(confirmed);
|
||||
return 'recovery';
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
|
||||
@@ -0,0 +1,365 @@
|
||||
function createAuthoredOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 50 }) {
|
||||
const storageKey = 'stackchain.authored-outbox.v1';
|
||||
const makeId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
const supportedKinds = new Set(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read', 'pull-review', 'issue-close', 'issue-blocker']);
|
||||
|
||||
function reviewFingerprint(message) {
|
||||
return JSON.stringify({
|
||||
body: String(message.body || ''),
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments : [],
|
||||
});
|
||||
}
|
||||
|
||||
function clearConfirmedReviewState(item) {
|
||||
if (item.kind !== 'pull-review') return;
|
||||
try {
|
||||
if (item.draftKey && item.draftFingerprint &&
|
||||
storage?.getItem(item.draftKey) === item.draftFingerprint) storage.removeItem(item.draftKey);
|
||||
if (item.progressKey && item.progressFingerprint &&
|
||||
storage?.getItem(item.progressKey) === item.progressFingerprint) storage.removeItem(item.progressKey);
|
||||
} catch (_error) { /* Delivery is confirmed even when local cleanup is unavailable. */ }
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && supportedKinds.has(item.kind));
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 2, items }));
|
||||
coordinator?.notify('authored');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'authored'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(message, mirror = true) {
|
||||
if (!supportedKinds.has(message?.kind)) throw new Error('This action cannot be queued.');
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing a message.');
|
||||
const items = read();
|
||||
const requestedOperationId = String(message.operationId || '').slice(0, 128);
|
||||
const existing = requestedOperationId && items.find(item => item.operationId === requestedOperationId);
|
||||
if (existing) return { ...existing };
|
||||
if (message.kind === 'pull-review') {
|
||||
const queuedReview = items.find(item => item.kind === 'pull-review' &&
|
||||
item.repository === String(message.repository || '') &&
|
||||
item.number === Number(message.number || 0) &&
|
||||
item.expectedHeadSha === String(message.expectedHeadSha || ''));
|
||||
if (queuedReview) {
|
||||
if (reviewFingerprint(queuedReview) === reviewFingerprint(message)) return { ...queuedReview };
|
||||
throw new Error('A review for this saved head is already queued. Open Drafts to inspect or discard it first.');
|
||||
}
|
||||
}
|
||||
if (items.length >= maxItems) throw new Error('Message outbox is full. Send or discard a queued message first.');
|
||||
const id = String(requestedOperationId || makeId()).slice(0, 128);
|
||||
const item = {
|
||||
id,
|
||||
operationId: requestedOperationId || id,
|
||||
kind: message.kind,
|
||||
repository: String(message.repository || ''),
|
||||
number: Number(message.number || 0),
|
||||
notificationId: Number(message.notificationId || 0),
|
||||
body: String(message.body || ''),
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
...(message.kind === 'update-reply-read' ? { replyConfirmed: message.replyConfirmed === true } : {}),
|
||||
...(['issue-comment', 'pull-comment', 'update-reply', 'update-reply-read'].includes(message.kind) && message.attachment ? {
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
stored: true,
|
||||
},
|
||||
} : {}),
|
||||
...(message.kind === 'pull-review' ? {
|
||||
decision: String(message.decision || 'comment'),
|
||||
expectedHeadSha: String(message.expectedHeadSha || ''),
|
||||
comments: Array.isArray(message.comments) ? message.comments.map(comment => ({ ...comment })) : [],
|
||||
draftKey: String(message.draftKey || ''),
|
||||
progressKey: String(message.progressKey || ''),
|
||||
draftFingerprint: String(message.draftFingerprint || ''),
|
||||
progressFingerprint: String(message.progressFingerprint || ''),
|
||||
} : {}),
|
||||
...(message.kind === 'issue-blocker' ? {
|
||||
blockerRepository: String(message.blockerRepository || ''),
|
||||
blockerNumber: Number(message.blockerNumber || 0),
|
||||
present: message.present === true,
|
||||
} : {}),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(message) {
|
||||
const item = enqueue(message, false);
|
||||
if (message.attachment && ['update-reply', 'update-reply-read'].includes(message.kind) &&
|
||||
(!backgroundSync?.reconcile || !backgroundSync?.requestSync)) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw new Error('Screenshot delivery needs IndexedDB. Your reply and screenshot are still here; retry.');
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const durableItems = read().map(candidate => candidate.id === item.id && message.attachment ? {
|
||||
...candidate,
|
||||
attachment: {
|
||||
filename: String(message.attachment.filename || ''),
|
||||
contentType: String(message.attachment.contentType || ''),
|
||||
...(message.attachment.blob ? { blob: message.attachment.blob } :
|
||||
{ data: String(message.attachment.data || '') }),
|
||||
},
|
||||
} : candidate);
|
||||
try {
|
||||
await backgroundSync.reconcile(durableItems, 'authored');
|
||||
} catch (error) {
|
||||
write(read().filter(candidate => candidate.id !== item.id), false);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await backgroundSync.requestSync();
|
||||
return { item, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, changes) {
|
||||
let updated = null;
|
||||
write(read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const body = String(changes?.body ?? item.body);
|
||||
updated = {
|
||||
...item,
|
||||
body,
|
||||
operationId: body === item.body ? item.operationId : String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
}));
|
||||
return updated;
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function endpoint(item) {
|
||||
if (item.kind === 'update-reply') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply';
|
||||
}
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close';
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers';
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review';
|
||||
}
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments';
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const request = (async () => {
|
||||
try {
|
||||
let result;
|
||||
if (backgroundSync?.send) {
|
||||
const delivery = await backgroundSync.send(item, currentLogin);
|
||||
if (delivery.attention) {
|
||||
const error = delivery.error || new Error('Message needs attention');
|
||||
error.status = Number(error.status || 422);
|
||||
throw error;
|
||||
}
|
||||
result = delivery.message;
|
||||
} else {
|
||||
if (item.kind === 'update-reply-read') {
|
||||
if (!item.replyConfirmed) {
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
});
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, replyConfirmed: true, status: 'sending', lastAttemptAt: attemptAt,
|
||||
} : candidate), false);
|
||||
}
|
||||
result = await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
} else if (item.kind === 'issue-close') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
});
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
} else if (item.kind === 'issue-blocker') {
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present,
|
||||
}),
|
||||
});
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present !== item.present) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
} else {
|
||||
const body = item.kind === 'pull-review' ? {
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments,
|
||||
} : { body: item.body };
|
||||
result = await fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!result) return { blocked: true };
|
||||
clearConfirmedReviewState(item);
|
||||
discard(item.id);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const permanent = status >= 400 && status < 500;
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (permanent) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate,
|
||||
status: 'attention',
|
||||
error: String(error.message || 'Message needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt,
|
||||
lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !permanent };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention' || item.kind === 'issue-close') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const outcome = await sendItem(item, currentLogin);
|
||||
if (outcome.result) confirmed.push(outcome.result);
|
||||
if (outcome.transient) break;
|
||||
}
|
||||
return { confirmed, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(makeId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const outcome = await sendItem(queued, currentLogin);
|
||||
return { confirmed: outcome.result ? [outcome.result] : [], remaining: read(), blocked: 0 };
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('authored', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
if (background?.status === 'sent') {
|
||||
clearConfirmedReviewState(item);
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...item,
|
||||
status: 'attention',
|
||||
error: String(background.error || 'Message needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
if (background?.status === 'authorization') return [{
|
||||
...item,
|
||||
status: 'authorization',
|
||||
error: String(background.error || 'Fresh authorization required').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueue, enqueueDurably, update, discard, flush, retry, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAuthoredOutbox;
|
||||
@@ -0,0 +1,816 @@
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-outbox-v1') {
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('issues')) {
|
||||
request.result.createObjectStore('issues', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const transact = async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('issues', 'readwrite');
|
||||
const objectStore = transaction.objectStore('issues');
|
||||
let result;
|
||||
let failed = false;
|
||||
transaction.oncomplete = () => failed ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error || new Error('Issue outbox transaction aborted'));
|
||||
Promise.resolve(work({
|
||||
get: id => requested(objectStore.get(id)),
|
||||
getAll: () => requested(objectStore.getAll()),
|
||||
put: value => requested(objectStore.put(value)),
|
||||
delete: id => requested(objectStore.delete(id)),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
failed = true;
|
||||
try { transaction.abort(); } catch (_abortError) { reject(error); }
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
transact.close = async () => {
|
||||
if (!databasePromise) return;
|
||||
const db = await databasePromise;
|
||||
db.close();
|
||||
databasePromise = undefined;
|
||||
};
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||
return {
|
||||
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||
get: id => transact(async records => {
|
||||
const value = await records.get(id);
|
||||
if (!value) return null;
|
||||
const {id: _id, ...attachment} = value;
|
||||
return attachment;
|
||||
}),
|
||||
delete: id => transact(records => records.delete(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
(Date.now().toString(36) + '-' + Math.random().toString(36).slice(2)),
|
||||
} = {}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
|
||||
async function reconcile(items, outboxLane = 'issue') {
|
||||
return transact(async records => {
|
||||
const existing = await records.getAll();
|
||||
const incoming = new Map(items.map(item => [item.id, { ...item, outboxLane }]));
|
||||
for (const current of existing) {
|
||||
if (current.recordType === 'receipt-preference') continue;
|
||||
const currentLane = current.outboxLane || 'issue';
|
||||
if (currentLane !== outboxLane) continue;
|
||||
let replacement = incoming.get(current.id);
|
||||
if (!replacement) {
|
||||
if (current.status !== 'sending' || Number(current.claimUntil) <= Number(now())) {
|
||||
await records.delete(current.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.operationId === replacement.operationId &&
|
||||
(current.attachment?.data || current.attachment?.blob) &&
|
||||
replacement.attachment?.stored &&
|
||||
!replacement.attachment.data && !replacement.attachment.blob) {
|
||||
replacement = { ...replacement, attachment: current.attachment };
|
||||
incoming.set(current.id, replacement);
|
||||
}
|
||||
if ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
replacement.status === current.status) ||
|
||||
current.status === 'sent') {
|
||||
incoming.set(current.id, current);
|
||||
}
|
||||
}
|
||||
for (const item of incoming.values()) await records.put(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function claimNext(ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const items = await records.getAll();
|
||||
const item = items.find(candidate => candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
if (!item) return null;
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function planBatch(ownerLogin, limit = 70) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const eligible = (await records.getAll()).filter(candidate =>
|
||||
candidate.recordType !== 'receipt-preference' &&
|
||||
candidate.ownerLogin === ownerLogin &&
|
||||
candidate.kind !== 'issue-close' &&
|
||||
(candidate.status === 'queued' || candidate.status === 'sending') &&
|
||||
(candidate.status !== 'sending' || Number(candidate.claimUntil) <= timestamp));
|
||||
const lanes = {
|
||||
issue: eligible.filter(item => (item.outboxLane || 'issue') !== 'authored'),
|
||||
authored: eligible.filter(item => item.outboxLane === 'authored'),
|
||||
};
|
||||
const selected = [];
|
||||
const maximum = Math.max(0, Number(limit) || 0);
|
||||
while (selected.length < maximum && (lanes.issue.length || lanes.authored.length)) {
|
||||
if (lanes.issue.length) selected.push(lanes.issue.shift().id);
|
||||
if (selected.length < maximum && lanes.authored.length) selected.push(lanes.authored.shift().id);
|
||||
}
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
async function claim(id, ownerLogin) {
|
||||
return transact(async records => {
|
||||
const timestamp = Number(now());
|
||||
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.ownerLogin !== ownerLogin ||
|
||||
!['queued', 'sending'].includes(item.status) ||
|
||||
(item.status === 'sending' && Number(item.claimUntil) > timestamp)) return null;
|
||||
const claimed = {
|
||||
...item, status: 'sending', claimUntil: timestamp + claimMs, claimToken: createToken(),
|
||||
};
|
||||
await records.put(claimed);
|
||||
return claimed;
|
||||
});
|
||||
}
|
||||
|
||||
async function upsert(item) {
|
||||
return transact(async records => {
|
||||
const current = (await records.getAll()).find(candidate => candidate.id === item.id);
|
||||
if (current && ((current.status === 'sending' && Number(current.claimUntil) > Number(now())) ||
|
||||
(['attention', 'authorization'].includes(current.status) &&
|
||||
item.status === current.status) ||
|
||||
current.status === 'sent')) return current;
|
||||
const preservedAttachment = (current?.attachment?.data || current?.attachment?.blob) &&
|
||||
item?.attachment?.stored && !item.attachment.data && !item.attachment.blob
|
||||
? { attachment: current.attachment } : {};
|
||||
const next = current && current.operationId === item.operationId ? {
|
||||
...item, ...preservedAttachment,
|
||||
...(current.deliveredIssue ? { deliveredIssue: current.deliveredIssue } : {}),
|
||||
...(current.attachmentMarkdown ? { attachmentMarkdown: current.attachmentMarkdown } : {}),
|
||||
} : { ...item };
|
||||
await records.put(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function update(id, transform) {
|
||||
return transact(async records => {
|
||||
const item = (await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (item) await records.put(transform(item));
|
||||
});
|
||||
}
|
||||
|
||||
async function updateClaim(id, claimToken, transform) {
|
||||
return transact(async records => {
|
||||
const item = records.get ? await records.get(id) :
|
||||
(await records.getAll()).find(candidate => candidate.id === id);
|
||||
if (!item || item.status !== 'sending' || !claimToken || item.claimToken !== claimToken) {
|
||||
return false;
|
||||
}
|
||||
await records.put(transform(item));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function clearClaim(item, changes) {
|
||||
const { claimToken: _claimToken, ...unclaimed } = item;
|
||||
return { ...unclaimed, ...changes, claimUntil: 0 };
|
||||
}
|
||||
|
||||
async function renew(id, claimToken) {
|
||||
let renewed = null;
|
||||
await updateClaim(id, claimToken, item => {
|
||||
renewed = { ...item, claimUntil: Number(now()) + claimMs };
|
||||
return renewed;
|
||||
});
|
||||
return renewed;
|
||||
}
|
||||
|
||||
function preferenceId(ownerLogin) {
|
||||
return 'receipt-preference:' + String(ownerLogin || '').trim();
|
||||
}
|
||||
|
||||
async function setReceiptPreference(ownerLogin, enabled) {
|
||||
const login = String(ownerLogin || '').trim();
|
||||
if (!login) return;
|
||||
return transact(async records => {
|
||||
const id = preferenceId(login);
|
||||
if (!enabled) return records.delete(id);
|
||||
return records.put({ id, recordType: 'receipt-preference', ownerLogin: login, enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function getReceiptPreference(ownerLogin) {
|
||||
const id = preferenceId(ownerLogin);
|
||||
return transact(async records => Boolean(
|
||||
(await records.getAll()).find(item => item.id === id)?.enabled
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
get: id => transact(records => records.get(id)),
|
||||
reconcile,
|
||||
upsert,
|
||||
update,
|
||||
claim,
|
||||
claimNext,
|
||||
planBatch,
|
||||
supportsClaimTokens: true,
|
||||
renew,
|
||||
checkpoint: updateClaim,
|
||||
complete: (id, claimToken, deliveredIssue) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'sent',
|
||||
...(item.completionIntent === 'create-and-start' && deliveredIssue ? { deliveredIssue } : {}),
|
||||
})),
|
||||
release: (id, claimToken) => updateClaim(id, claimToken,
|
||||
item => clearClaim(item, { status: 'queued' })),
|
||||
fail: (id, claimToken, error, deliveryState) => updateClaim(id, claimToken, item => clearClaim(item, {
|
||||
status: 'attention', error,
|
||||
...(deliveryState ? { deliveryState } : {}),
|
||||
})),
|
||||
authorization: (id, claimToken, error) => updateClaim(
|
||||
id, claimToken, item => clearClaim(item, { status: 'authorization', error })
|
||||
),
|
||||
snapshot: () => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference')),
|
||||
countBlocked: ownerLogin => transact(async records =>
|
||||
(await records.getAll()).filter(item => item.recordType !== 'receipt-preference' &&
|
||||
item.status !== 'sent' && item.ownerLogin !== ownerLogin).length),
|
||||
setReceiptPreference,
|
||||
getReceiptPreference,
|
||||
close: () => transact.close?.(),
|
||||
};
|
||||
}
|
||||
|
||||
function createBackgroundIssueSync({
|
||||
store, fetchJson, base = '', maxConcurrency = 3, batchSize = 70,
|
||||
batch = work => work(), requestTimeoutMs = 15000,
|
||||
}) {
|
||||
let purgeRequested = false;
|
||||
let activePurge = null;
|
||||
let activeFlush = null;
|
||||
const activeRequests = new Set();
|
||||
const timeoutMs = Math.max(1, Number(requestTimeoutMs) || 15000);
|
||||
|
||||
const completeClaim = (item, delivered) => store.supportsClaimTokens
|
||||
? store.complete(item.id, item.claimToken, delivered) : store.complete(item.id, delivered);
|
||||
const releaseClaim = item => store.supportsClaimTokens
|
||||
? store.release(item.id, item.claimToken) : store.release(item.id);
|
||||
const failClaim = (item, error, deliveryState) => store.supportsClaimTokens
|
||||
? store.fail(item.id, item.claimToken, error, deliveryState)
|
||||
: store.fail(item.id, error, deliveryState);
|
||||
const requireAuthorization = (item, error) => store.supportsClaimTokens
|
||||
? store.authorization(item.id, item.claimToken, error)
|
||||
: store.authorization(item.id, error);
|
||||
const checkpointClaim = (item, transform) => store.supportsClaimTokens
|
||||
? store.checkpoint(item.id, item.claimToken, transform) : store.update?.(item.id, transform);
|
||||
|
||||
async function requestStage(item, url, options) {
|
||||
if (store.renew) {
|
||||
const renewed = await store.renew(item.id, item.claimToken);
|
||||
if (!renewed) throw new Error('Background delivery claim was lost.');
|
||||
}
|
||||
return requestJson(url, options);
|
||||
}
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
activeRequests.add(controller);
|
||||
let timer;
|
||||
const interrupted = new Promise((resolve, reject) => {
|
||||
controller.signal.addEventListener('abort', () => {
|
||||
reject(new Error(purgeRequested
|
||||
? 'Background request canceled.'
|
||||
: 'Background request timed out.'));
|
||||
}, { once: true });
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchJson(url, { ...options, signal: controller.signal })),
|
||||
interrupted,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
activeRequests.delete(controller);
|
||||
}
|
||||
}
|
||||
function receiptFor(item, status, delivered = {}) {
|
||||
if (status === 'attention') {
|
||||
return { id: item.id, status, kind: item.kind ? 'message' : 'issue', route: '#/my-work/drafts' };
|
||||
}
|
||||
if (item.kind === 'notification-read') {
|
||||
return { id: item.id, status, kind: 'notification-read', route: '#/my-work/updates' };
|
||||
}
|
||||
if (item.kind === 'update-reply' || item.kind === 'update-reply-read') {
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/update/' + encodeURIComponent(item.notificationId) };
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/review/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
id: item.id, status, kind: 'message',
|
||||
route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(item.number),
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pull' : 'issue';
|
||||
return { id: item.id, status, kind: 'message', route: '#/my-work/' + resource + '/' + repository + '/' + encodeURIComponent(item.number) };
|
||||
}
|
||||
return { id: item.id, status, kind: 'issue', route: '#/my-work/issue/' + repository + '/' + encodeURIComponent(delivered.number) };
|
||||
}
|
||||
|
||||
function deliveryRequest(item) {
|
||||
if (item.kind === 'notification-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
if (item.kind === 'update-reply') {
|
||||
return authoredRequest('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/reply', item);
|
||||
}
|
||||
if (item.kind === 'update-reply-read') {
|
||||
return {
|
||||
url: base + 'api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read',
|
||||
options: { method: 'PATCH', headers: { Accept: 'application/json' } },
|
||||
};
|
||||
}
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
if (item.kind === 'issue-close') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/close',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': item.operationId },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(item.number) + '/blockers',
|
||||
options: {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
repository: item.blockerRepository,
|
||||
number: item.blockerNumber,
|
||||
present: item.present === true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'pull-review') {
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/pulls/' + encodeURIComponent(item.number) + '/review',
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: item.body,
|
||||
decision: item.decision,
|
||||
expected_head_sha: item.expectedHeadSha,
|
||||
comments: item.comments || [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (item.kind === 'issue-comment' || item.kind === 'pull-comment') {
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
return authoredRequest(
|
||||
'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
item,
|
||||
);
|
||||
}
|
||||
return {
|
||||
url: base + 'api/v1/repos/' + repository + '/issues',
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title,
|
||||
body: item.body,
|
||||
label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authoredRequest(url, item) {
|
||||
return {
|
||||
url: base + url,
|
||||
options: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({ body: item.body }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
async function deliverReplyRead(item) {
|
||||
let current = item;
|
||||
if (!current.replyConfirmed) {
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (current.attachment && !attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
const replyBody = attachmentMarkdown ?
|
||||
(text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown) : text;
|
||||
const options = authoredRequest('', { ...current, body:replyBody }).options;
|
||||
if (current.attachment) options.headers['Idempotency-Key'] = stageOperationId(current.operationId, 'reply');
|
||||
await requestStage(
|
||||
current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply',
|
||||
options,
|
||||
);
|
||||
const checkpointed = await checkpointClaim(current, stored => ({ ...stored, replyConfirmed: true }));
|
||||
if (checkpointed === false) throw new Error('Background delivery claim was lost.');
|
||||
current = { ...current, replyConfirmed: true };
|
||||
}
|
||||
const request = deliveryRequest(current);
|
||||
return requestStage(current, request.url, request.options);
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function deliverIssueCapture(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
let deliveredIssue = item.deliveredIssue;
|
||||
if (!deliveredIssue) {
|
||||
const request = deliveryRequest(item);
|
||||
deliveredIssue = await requestStage(item, request.url, request.options);
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue }));
|
||||
}
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, deliveredIssue, attachmentMarkdown }));
|
||||
}
|
||||
await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(deliveredIssue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
return deliveredIssue;
|
||||
}
|
||||
|
||||
async function deliverScreenshotComment(item) {
|
||||
const repository = String(item.repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const resource = item.kind === 'pull-comment' ? 'pulls' : 'issues';
|
||||
let attachmentMarkdown = item.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(item, current => ({ ...current, attachmentMarkdown }));
|
||||
}
|
||||
const text = String(item.body || '').trim();
|
||||
return requestStage(
|
||||
item,
|
||||
base + 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(item.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverUpdateScreenshotReply(item) {
|
||||
let current = item;
|
||||
let attachmentMarkdown = current.attachmentMarkdown;
|
||||
if (!attachmentMarkdown) {
|
||||
const uploaded = await requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/attachments', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Idempotency-Key':stageOperationId(current.operationId, 'attachment') },
|
||||
body:attachmentMultipart(current.attachment),
|
||||
});
|
||||
attachmentMarkdown = String(uploaded?.markdown || '');
|
||||
if (!attachmentMarkdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
await checkpointClaim(current, stored => ({ ...stored, attachmentMarkdown }));
|
||||
current = { ...current, attachmentMarkdown };
|
||||
}
|
||||
const text = String(current.body || '').trim();
|
||||
return requestStage(current,
|
||||
base + 'api/v1/notifications/' + encodeURIComponent(current.notificationId) + '/reply', {
|
||||
method:'POST',
|
||||
headers:{ Accept:'application/json', 'Content-Type':'application/json',
|
||||
'Idempotency-Key':stageOperationId(current.operationId, 'reply') },
|
||||
body:JSON.stringify({ body:text ? text + '\n\n' + attachmentMarkdown : attachmentMarkdown }),
|
||||
});
|
||||
}
|
||||
|
||||
async function deliver(item) {
|
||||
const request = deliveryRequest(item);
|
||||
try {
|
||||
const delivered = item.kind === 'update-reply-read' ? await deliverReplyRead(item) :
|
||||
item.kind === 'update-reply' && item.attachment ? await deliverUpdateScreenshotReply(item) :
|
||||
item.attachment && ['issue-comment', 'pull-comment'].includes(item.kind) ?
|
||||
await deliverScreenshotComment(item) : item.attachment && !item.kind ?
|
||||
await deliverIssueCapture(item) : await requestStage(item, request.url, request.options);
|
||||
if (item.kind === 'issue-close' && delivered?.state !== 'closed') {
|
||||
const error = new Error('Issue closure was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
if (item.kind === 'issue-blocker') {
|
||||
const dependencies = Array.isArray(delivered?.dependencies) ? delivered.dependencies : [];
|
||||
const present = dependencies.some(candidate => candidate?.repository === item.blockerRepository &&
|
||||
Number(candidate?.number) === item.blockerNumber);
|
||||
if (delivered?.number !== item.number || delivered?.dependencies_available !== true || present !== item.present) {
|
||||
const error = new Error('Blocker change was not confirmed.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await completeClaim(item, delivered);
|
||||
const receipt = receiptFor(item, 'confirmed', delivered);
|
||||
return item.kind ? { message: delivered, receipt } : { issue: delivered, receipt };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status === 401) {
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
if (status === 428 && item.kind === 'pull-review') {
|
||||
const message = String(error?.message || 'Fresh authorization required').slice(0, 240);
|
||||
await requireAuthorization(item, message);
|
||||
return {
|
||||
authorization: true,
|
||||
error,
|
||||
receipt: receiptFor(item, 'authorization'),
|
||||
};
|
||||
}
|
||||
if (status >= 400 && status < 500) {
|
||||
await failClaim(
|
||||
item,
|
||||
String(error?.message || 'Issue needs attention').slice(0, 240),
|
||||
error?.code === 'delivery_uncertain' ? 'uncertain' : undefined,
|
||||
);
|
||||
return { attention: true, error, receipt: receiptFor(item, 'attention') };
|
||||
}
|
||||
await releaseClaim(item);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (purgeRequested) return { blocked: true };
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
await store.upsert(item);
|
||||
const claimed = await store.claim(item.id, currentLogin);
|
||||
if (!claimed) return { busy: true };
|
||||
return deliver(claimed);
|
||||
}
|
||||
|
||||
async function runFlush() {
|
||||
const identity = await requestJson(base + 'api/v1/background-identity', {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store',
|
||||
});
|
||||
const login = String(identity?.login || '').trim();
|
||||
const confirmed = [];
|
||||
const receipts = [];
|
||||
let attention = 0;
|
||||
let authorization = 0;
|
||||
if (!login) return { confirmed, blocked: 0, attention, authorization, login, receipts };
|
||||
const collect = result => {
|
||||
if (result.issue) confirmed.push(result.issue);
|
||||
if (result.message) confirmed.push(result.message);
|
||||
if (result.attention) attention += 1;
|
||||
if (result.authorization) authorization += 1;
|
||||
if (result.receipt) receipts.push(result.receipt);
|
||||
};
|
||||
if (store.planBatch) {
|
||||
const planned = await store.planBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < planned.length) {
|
||||
const id = planned[next++];
|
||||
const item = await store.claim(id, login);
|
||||
if (!item) continue;
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, planned.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) throw new Error('Background delivery canceled.');
|
||||
if (authenticationError) throw authenticationError;
|
||||
if (transientError) throw transientError;
|
||||
} else if (store.claimBatch) {
|
||||
const claimed = await store.claimBatch(login, batchSize);
|
||||
let next = 0;
|
||||
let authenticationError = null;
|
||||
let transientError = null;
|
||||
const worker = async () => {
|
||||
while (!purgeRequested && !authenticationError && next < claimed.length) {
|
||||
const item = claimed[next++];
|
||||
try {
|
||||
collect(await deliver(item));
|
||||
} catch (error) {
|
||||
if (Number(error?.status || 0) === 401) authenticationError = error;
|
||||
else if (!transientError) transientError = error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const concurrency = Math.max(1, Math.min(Number(maxConcurrency) || 1, claimed.length));
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
if (purgeRequested) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw new Error('Background delivery canceled.');
|
||||
}
|
||||
if (authenticationError) {
|
||||
await Promise.all(claimed.slice(next).map(item => store.release(item.id)));
|
||||
throw authenticationError;
|
||||
}
|
||||
if (transientError) throw transientError;
|
||||
} else {
|
||||
while (!purgeRequested) {
|
||||
const item = await store.claimNext(login);
|
||||
if (!item) break;
|
||||
collect(await deliver(item));
|
||||
}
|
||||
}
|
||||
const blocked = store.countBlocked ? await store.countBlocked(login) : 0;
|
||||
return { confirmed, blocked, attention, authorization, login, receipts };
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (purgeRequested) return Promise.resolve({ confirmed: [], blocked: 0, attention: 0, login: '', receipts: [] });
|
||||
if (activeFlush) return activeFlush;
|
||||
activeFlush = Promise.resolve().then(() => batch(runFlush)).finally(() => { activeFlush = null; });
|
||||
return activeFlush;
|
||||
}
|
||||
|
||||
function purge() {
|
||||
if (activePurge) return activePurge;
|
||||
purgeRequested = true;
|
||||
activeRequests.forEach(controller => controller.abort());
|
||||
activePurge = (async () => {
|
||||
if (activeFlush) await activeFlush.catch(() => {});
|
||||
await store.close?.();
|
||||
})();
|
||||
return activePurge;
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
const pendingPurge = activePurge;
|
||||
if (pendingPurge) await pendingPurge;
|
||||
if (activePurge === pendingPurge) activePurge = null;
|
||||
purgeRequested = false;
|
||||
}
|
||||
|
||||
return {
|
||||
flush, send, purge, resume,
|
||||
get: id => store.get(id),
|
||||
reconcile: (items, outboxLane) => store.reconcile(items, outboxLane),
|
||||
snapshot: () => store.snapshot(),
|
||||
setReceiptPreference: (ownerLogin, enabled) => store.setReceiptPreference(ownerLogin, enabled),
|
||||
getReceiptPreference: ownerLogin => store.getReceiptPreference(ownerLogin),
|
||||
};
|
||||
}
|
||||
|
||||
createBackgroundIssueSync.createIssueSyncStore = createIssueSyncStore;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBackgroundIssueSync;
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
function createBatchFindWork({
|
||||
capacity,
|
||||
claim,
|
||||
queue,
|
||||
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
|
||||
persistEstimate = () => {},
|
||||
onProgress = () => {},
|
||||
storage = typeof localStorage === 'undefined' ? null : localStorage,
|
||||
owner = () => '',
|
||||
}) {
|
||||
let request = null;
|
||||
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
|
||||
|
||||
function readJournal() {
|
||||
if (!storage || !String(owner() || '')) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
|
||||
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeJournal(value) {
|
||||
if (!storage || !String(owner() || '')) return;
|
||||
storage.setItem(journalKey(), JSON.stringify(value));
|
||||
}
|
||||
|
||||
function clearJournal() {
|
||||
if (storage && String(owner() || '')) storage.removeItem(journalKey());
|
||||
}
|
||||
|
||||
function result(status, selected, available, queued = [], failed = []) {
|
||||
return { status, selected, available, queued, failed };
|
||||
}
|
||||
|
||||
function key(item) {
|
||||
return String(item.repository || '') + '#' + String(item.number || '');
|
||||
}
|
||||
|
||||
async function processJournal(journal) {
|
||||
const selected = journal.items;
|
||||
const queued = [];
|
||||
const failed = [];
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const entry = selected[index];
|
||||
const item = entry.item;
|
||||
if (entry.state === 'queued') {
|
||||
queued.push(key(item));
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const confirmed = entry.confirmed || await claim(item);
|
||||
if (!entry.confirmed) {
|
||||
entry.confirmed = confirmed;
|
||||
entry.state = 'assigned';
|
||||
writeJournal(journal);
|
||||
}
|
||||
const queueResult = await queue(confirmed);
|
||||
if (queueResult === 'queued' || queueResult === 'exists') {
|
||||
queued.push(key(item));
|
||||
entry.state = 'queued';
|
||||
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
|
||||
persistEstimate(confirmed, entry.estimate);
|
||||
}
|
||||
writeJournal(journal);
|
||||
} else {
|
||||
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
|
||||
}
|
||||
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
|
||||
}
|
||||
const outcome = result('complete', selected.length, journal.available, queued, failed);
|
||||
if (!failed.length) clearJournal();
|
||||
else writeJournal(journal);
|
||||
onProgress({ ...outcome, processed: selected.length });
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function run(items, estimates = {}) {
|
||||
if (request) return request;
|
||||
const selected = Array.isArray(items) ? items.slice() : [];
|
||||
const available = Math.max(0, Number(capacity()) || 0);
|
||||
if (selected.length > available) {
|
||||
const outcome = result('full', selected.length, available);
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
const budget = timeBudget() || {};
|
||||
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
|
||||
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
|
||||
const invalid = selected.map(key).filter(id =>
|
||||
!Number.isInteger(estimates[id]) || estimates[id] <= 0
|
||||
);
|
||||
const requested = selected.reduce((sum, item) => sum +
|
||||
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
|
||||
if (invalid.length) {
|
||||
const outcome = { ...result('estimates-required', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, invalid };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
if (requested > remaining) {
|
||||
const outcome = { ...result('over-budget', selected.length, available),
|
||||
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
|
||||
onProgress(outcome);
|
||||
return Promise.resolve(outcome);
|
||||
}
|
||||
}
|
||||
const journal = {
|
||||
owner: String(owner() || ''), available,
|
||||
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
|
||||
};
|
||||
writeJournal(journal);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (request) return request;
|
||||
const journal = readJournal();
|
||||
if (!journal) return Promise.resolve(null);
|
||||
request = processJournal(journal).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function mountRecovery(button, opener) {
|
||||
const show = () => {
|
||||
const journal = readJournal();
|
||||
button.hidden = !journal;
|
||||
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
|
||||
};
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
const outcome = await resume();
|
||||
button.disabled = false;
|
||||
show();
|
||||
if (outcome) {
|
||||
button.previousElementSibling.textContent = outcome.failed.length ?
|
||||
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
|
||||
}
|
||||
});
|
||||
opener.addEventListener('click', show);
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') mountRecovery(
|
||||
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
|
||||
);
|
||||
return { run, resume, mountRecovery };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.createCardPlanning = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
|
||||
function sync(disclosure) {
|
||||
disclosure.querySelector('summary')?.setAttribute(
|
||||
'aria-expanded', String(disclosure.open)
|
||||
);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
|
||||
disclosures.forEach(disclosure => {
|
||||
if (disclosure.dataset.cardPlanningBound === 'true') return;
|
||||
disclosure.dataset.cardPlanningBound = 'true';
|
||||
sync(disclosure);
|
||||
disclosure.addEventListener('toggle', () => {
|
||||
if (disclosure.open) {
|
||||
disclosures.forEach(other => {
|
||||
if (other === disclosure || !other.open) return;
|
||||
other.open = false;
|
||||
sync(other);
|
||||
});
|
||||
}
|
||||
sync(disclosure);
|
||||
});
|
||||
disclosure.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
disclosure.open = false;
|
||||
sync(disclosure);
|
||||
disclosure.querySelector('summary')?.focus();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { wire };
|
||||
});
|
||||
+50
-1
@@ -3,8 +3,57 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = filterCommands;
|
||||
if (root) root.filterCommands = filterCommands;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function filterCommands(commands, filter) {
|
||||
function filterCommands(commands, filter) {
|
||||
const term = String(filter || '').toLowerCase();
|
||||
return commands.filter(command => command.name.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
filterCommands.nextSelection = function nextSelection(current, key, count) {
|
||||
if (count < 1) return -1;
|
||||
if (key === 'ArrowDown') return (current + 1 + count) % count;
|
||||
if (key === 'ArrowUp') return (current - 1 + count) % count;
|
||||
return current;
|
||||
};
|
||||
|
||||
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
|
||||
const search = options.search;
|
||||
const onState = options.onState;
|
||||
const delay = options.delay === undefined ? 250 : options.delay;
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
let activeController = null;
|
||||
|
||||
return {
|
||||
setQuery(value) {
|
||||
const query = String(value || '').trim();
|
||||
generation += 1;
|
||||
const current = generation;
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
if (activeController !== null) activeController.abort();
|
||||
activeController = null;
|
||||
if (query.length < 2) {
|
||||
onState({ status: 'idle', query, items: [] });
|
||||
return;
|
||||
}
|
||||
onState({ status: 'loading', query, items: [] });
|
||||
timer = setTimeout(async () => {
|
||||
const requestController = new AbortController();
|
||||
activeController = requestController;
|
||||
try {
|
||||
const result = await search(query, requestController.signal);
|
||||
const items = Array.isArray(result) ? result : result.items;
|
||||
const partial = !Array.isArray(result) && result.partial === true;
|
||||
if (current === generation) onState({ status: 'ready', query, items, partial });
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
if (current === generation) onState({ status: 'error', query, items: [], error });
|
||||
} finally {
|
||||
if (activeController === requestController) activeController = null;
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return filterCommands;
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
|
||||
const encodedRepository = repository => String(repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/');
|
||||
|
||||
function pathFor(context, commentId) {
|
||||
const item = context?.item || {};
|
||||
if (context?.kind === 'update') {
|
||||
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
|
||||
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
|
||||
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
|
||||
'/comments/' + encodeURIComponent(commentId);
|
||||
}
|
||||
|
||||
const controller = {
|
||||
isOwned(comment) {
|
||||
const login = String(getLogin() || '').trim();
|
||||
return Boolean(login && comment && comment.author === login);
|
||||
},
|
||||
async edit(context, pager, commentId, body) {
|
||||
const draft = String(body || '').trim();
|
||||
if (!draft) throw new Error('Comment must not be blank.');
|
||||
const comment = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: draft }),
|
||||
});
|
||||
pager.replace(comment);
|
||||
return pager.snapshot();
|
||||
},
|
||||
async remove(context, pager, commentId) {
|
||||
if (!confirmDelete('Delete this comment permanently?')) return null;
|
||||
const result = await fetchJson(pathFor(context, commentId), {
|
||||
method: 'DELETE', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
|
||||
throw new Error('Comment deletion was not confirmed.');
|
||||
}
|
||||
pager.remove(commentId);
|
||||
return pager.snapshot();
|
||||
},
|
||||
actionHtml(comment) {
|
||||
return controller.isOwned(comment) ?
|
||||
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
|
||||
'<button type="button" data-comment-action="edit">Edit</button>' +
|
||||
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
|
||||
},
|
||||
wire({ root, getSurface, isOffline, escapeHtml }) {
|
||||
root.addEventListener('click', async event => {
|
||||
const button = event.target.closest('[data-comment-action]');
|
||||
if (!button) return;
|
||||
const card = button.closest('.issue-comment');
|
||||
const commentId = Number(card?.dataset.commentId);
|
||||
const surface = getSurface();
|
||||
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
|
||||
if (!comment || !controller.isOwned(comment)) return;
|
||||
if (isOffline()) {
|
||||
surface.status.textContent = 'Reconnect to edit or delete this comment.';
|
||||
return;
|
||||
}
|
||||
if (button.dataset.commentAction === 'delete') {
|
||||
try {
|
||||
const state = await controller.remove(surface.context, surface.pager, commentId);
|
||||
if (state) {
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment deleted.';
|
||||
}
|
||||
} catch (error) {
|
||||
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
card.innerHTML = '<div class="small">Editing your comment</div>' +
|
||||
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
|
||||
escapeHtml(comment.body || '') + '</textarea>' +
|
||||
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
|
||||
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
|
||||
const textarea = card.querySelector('.comment-edit-textarea');
|
||||
textarea.focus();
|
||||
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
|
||||
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
|
||||
const save = saveEvent.currentTarget;
|
||||
save.disabled = true;
|
||||
surface.status.textContent = 'Saving comment…';
|
||||
try {
|
||||
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
|
||||
surface.render(state);
|
||||
surface.status.textContent = 'Comment updated.';
|
||||
} catch (error) {
|
||||
save.disabled = false;
|
||||
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
|
||||
@@ -0,0 +1,61 @@
|
||||
function createCommentNext({ post, queue, queueKind = '', canQueue, accept = () => undefined, complete }) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admitQueued(item, message) {
|
||||
const admission = await queue(message);
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Comment was not saved for delivery.');
|
||||
}
|
||||
accept(item, { delivery: admission.background ? 'queued' : 'saved' });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
background: Boolean(admission.background),
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId = '') {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
let comment;
|
||||
try {
|
||||
comment = await post(item, body, typeof operationId === 'function' ? operationId() : operationId);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
const identity = queueKind === 'update-reply' ? {
|
||||
kind: 'update-reply', notificationId: item.notification_id,
|
||||
} : {
|
||||
kind: item.kind === 'pull' ? 'pull-comment' : 'issue-comment',
|
||||
repository: item.repository, number: item.number,
|
||||
};
|
||||
return admitQueued(item, {
|
||||
...identity, body,
|
||||
operationId: typeof operationId === 'function' ? operationId() : operationId,
|
||||
});
|
||||
}
|
||||
accept(item, { delivery: 'posted', comment });
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: 'posted',
|
||||
comment,
|
||||
completed: Boolean(complete(item)),
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
function admit(item, message) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = admitQueued(item, message).finally(() => { inFlight = null; });
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, admit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentNext;
|
||||
+139
-14
@@ -1,3 +1,22 @@
|
||||
function buildLiveRevisionQuery(revisions = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const tokenPattern = /^[0-9a-f]{16}\.[0-9]{1,20}$/;
|
||||
['context', 'events', 'notifications'].forEach((section) => {
|
||||
const revision = revisions[section];
|
||||
if (typeof revision === 'string' && tokenPattern.test(revision)) {
|
||||
params.set(section + '_revision', revision);
|
||||
}
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function retryAfterMs(value) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
||||
return Math.min(seconds * 1000, 300000);
|
||||
}
|
||||
|
||||
function createContextPoller({
|
||||
fetchContext,
|
||||
onSnapshot,
|
||||
@@ -5,69 +24,175 @@ function createContextPoller({
|
||||
isHidden = () => false,
|
||||
setTimer = setTimeout,
|
||||
clearTimer = clearTimeout,
|
||||
setDeadlineTimer = setTimeout,
|
||||
clearDeadlineTimer = clearTimeout,
|
||||
intervalMs = 8000,
|
||||
timeoutMs = 12000,
|
||||
maxBackoffMs = 60000,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
let activeRequest = null;
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
let revisions = {};
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
const sections = freshness && freshness.sections;
|
||||
const sectionValues = sections && Object.values(sections);
|
||||
if (sectionValues && sectionValues.length && sectionValues.every(section => section.degraded)) {
|
||||
const retrySeconds = Number(freshness.retry_in_seconds);
|
||||
if (Number.isFinite(retrySeconds) && retrySeconds > 0) return retrySeconds * 1000;
|
||||
}
|
||||
const freshForSeconds = Number(freshness && freshness.fresh_for_seconds);
|
||||
const healthyDeadlines = (sectionValues || [])
|
||||
.filter(section => !section.degraded)
|
||||
.map(section => freshForSeconds - Number(section.age_seconds))
|
||||
.filter(seconds => Number.isFinite(seconds));
|
||||
if (Number.isFinite(freshForSeconds) && freshForSeconds > 0 && healthyDeadlines.length) {
|
||||
const earliestDeadline = Math.min(...healthyDeadlines);
|
||||
if (earliestDeadline <= 0 && sectionValues.some(section => !section.degraded && section.revalidating)) {
|
||||
return intervalMs;
|
||||
}
|
||||
return Math.max(0, earliestDeadline) * 1000;
|
||||
}
|
||||
return intervalMs;
|
||||
}
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, intervalMs);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function abortError() {
|
||||
const error = new Error('Live update superseded.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function supersede(request) {
|
||||
if (!request || request.settled) return;
|
||||
request.superseded = true;
|
||||
request.controller.abort();
|
||||
request.rejectDeadline(abortError());
|
||||
}
|
||||
|
||||
function refresh(options = {}) {
|
||||
if (stopped || isHidden()) return Promise.resolve(null);
|
||||
if (inFlight) return inFlight;
|
||||
if (activeRequest && !options.force) return activeRequest.promise;
|
||||
if (activeRequest) supersede(activeRequest);
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadlinePromise = new Promise((resolve, reject) => {
|
||||
rejectDeadline = reject;
|
||||
});
|
||||
const requestState = {
|
||||
controller,
|
||||
deadline: null,
|
||||
promise: null,
|
||||
rejectDeadline,
|
||||
settled: false,
|
||||
superseded: false,
|
||||
};
|
||||
activeRequest = requestState;
|
||||
requestState.deadline = setDeadlineTimer(() => {
|
||||
if (requestState.settled || requestState.superseded) return;
|
||||
controller.abort();
|
||||
const error = new Error(`Live update timed out after ${timeoutMs}ms.`);
|
||||
error.name = 'TimeoutError';
|
||||
rejectDeadline(error);
|
||||
}, timeoutMs);
|
||||
|
||||
let request;
|
||||
try {
|
||||
request = fetchContext();
|
||||
request = fetchContext({ ...revisions }, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
inFlight = Promise.resolve(request)
|
||||
requestState.promise = Promise.race([Promise.resolve(request), deadlinePromise])
|
||||
.then((snapshot) => {
|
||||
onSnapshot(snapshot);
|
||||
return snapshot;
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
);
|
||||
retainedSnapshot = retainedSnapshot ? { ...retainedSnapshot, ...snapshot } : { ...snapshot };
|
||||
revisions = { ...revisions, ...(snapshot.revisions || {}) };
|
||||
onSnapshot(retainedSnapshot, changedSections);
|
||||
return retainedSnapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
onError(error);
|
||||
if (activeRequest === requestState && !stopped) {
|
||||
const retryAfterMs = Number(error && error.retryAfterMs);
|
||||
failureStreak += 1;
|
||||
nextDelayMs = Number.isFinite(retryAfterMs) && retryAfterMs > 0
|
||||
? retryAfterMs
|
||||
: Math.min(intervalMs * (2 ** (failureStreak - 1)), maxBackoffMs);
|
||||
onError(error);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
schedule();
|
||||
requestState.settled = true;
|
||||
clearDeadlineTimer(requestState.deadline);
|
||||
if (activeRequest !== requestState) return;
|
||||
activeRequest = null;
|
||||
schedule(nextDelayMs);
|
||||
});
|
||||
return inFlight;
|
||||
return requestState.promise;
|
||||
}
|
||||
|
||||
function setVisible(visible) {
|
||||
cancelTimer();
|
||||
if (!visible) return Promise.resolve(null);
|
||||
return refresh();
|
||||
return refresh({ force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
if (activeRequest) {
|
||||
const request = activeRequest;
|
||||
activeRequest = null;
|
||||
supersede(request);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createContextPoller.buildRevisionQuery = buildLiveRevisionQuery;
|
||||
createContextPoller.retryAfterMs = retryAfterMs;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createContextPoller;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
function createConversationPager({ loadPage }) {
|
||||
let state = { comments: [], page: 1, older_page: null, total: 0 };
|
||||
let olderRequest = null;
|
||||
|
||||
const validComments = comments => Array.isArray(comments)
|
||||
? comments.filter(comment => comment && Number.isInteger(comment.id))
|
||||
: [];
|
||||
|
||||
const unique = comments => {
|
||||
const seen = new Set();
|
||||
return comments.filter(comment => {
|
||||
if (seen.has(comment.id)) return false;
|
||||
seen.add(comment.id);
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
const leftTime = Date.parse(left.created_at || '');
|
||||
const rightTime = Date.parse(right.created_at || '');
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return left.id - right.id;
|
||||
});
|
||||
};
|
||||
|
||||
const snapshot = () => ({ ...state, comments: state.comments.map(comment => ({ ...comment })) });
|
||||
|
||||
return {
|
||||
reset(page) {
|
||||
const comments = unique(validComments(page?.comments));
|
||||
state = {
|
||||
comments,
|
||||
page: Number.isInteger(page?.page) ? page.page : 1,
|
||||
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
|
||||
total: Number.isInteger(page?.total) ? Math.max(page.total, comments.length) : comments.length,
|
||||
};
|
||||
olderRequest = null;
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
loadOlder() {
|
||||
if (olderRequest) return olderRequest;
|
||||
if (!Number.isInteger(state.older_page)) return Promise.resolve(snapshot());
|
||||
const requestedPage = state.older_page;
|
||||
olderRequest = Promise.resolve(loadPage(requestedPage)).then(page => {
|
||||
state = {
|
||||
comments: unique(validComments(page?.comments).concat(state.comments)),
|
||||
page: Number.isInteger(page?.page) ? page.page : requestedPage,
|
||||
older_page: Number.isInteger(page?.older_page) ? page.older_page : null,
|
||||
total: Number.isInteger(page?.total) ? Math.max(page.total, state.comments.length) : state.total,
|
||||
};
|
||||
return snapshot();
|
||||
}).finally(() => { olderRequest = null; });
|
||||
return olderRequest;
|
||||
},
|
||||
append(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
if (!state.comments.some(existing => existing.id === comment.id)) {
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.concat(comment),
|
||||
total: Math.max(state.total + 1, state.comments.length + 1),
|
||||
};
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
replace(comment) {
|
||||
if (!comment || !Number.isInteger(comment.id)) return snapshot();
|
||||
state = {
|
||||
...state,
|
||||
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
|
||||
};
|
||||
return snapshot();
|
||||
},
|
||||
remove(commentId) {
|
||||
const comments = state.comments.filter(comment => comment.id !== commentId);
|
||||
if (comments.length !== state.comments.length) {
|
||||
state = { ...state, comments, total: Math.max(0, state.total - 1) };
|
||||
}
|
||||
return snapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createConversationPager;
|
||||
@@ -0,0 +1,36 @@
|
||||
function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, announce }) {
|
||||
const completed = new Set();
|
||||
|
||||
function available() {
|
||||
return todayWork.read().length < todayWork.limit;
|
||||
}
|
||||
|
||||
function complete(issue) {
|
||||
const identity = todayWork.identity(issue);
|
||||
if (!identity || completed.has(identity)) return 'exists';
|
||||
const added = todayWork.add(issue);
|
||||
if (added === 'full') {
|
||||
announce('Created, but Today changed—remove an item, then add this issue.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Created, but Today could not be saved on this device.');
|
||||
return 'unavailable';
|
||||
}
|
||||
if (!todaySync.enqueue('add', identity)) {
|
||||
announce('Created and saved to Today on this device, but account sync is unavailable.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
completed.add(identity);
|
||||
refresh();
|
||||
todaySync.flush();
|
||||
warm();
|
||||
start(issue);
|
||||
announce('Created, added to Today, and ready to work.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
return { available, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
|
||||
@@ -0,0 +1,300 @@
|
||||
function newIssueOperationId() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return String(Date.now()) + '-' + Math.random().toString(16).slice(2);
|
||||
}
|
||||
|
||||
function normalizeSharedContent(value = {}) {
|
||||
const clean = input => String(input || '').replace(/\s+/g, ' ').trim();
|
||||
const text = String(value.text || '').trim().slice(0, 9500);
|
||||
const textSummary = clean(text);
|
||||
const sentence = (textSummary.match(/^.{1,80}?[.!?](?:\s|$)/)?.[0] || textSummary.slice(0, 80)).trim();
|
||||
const title = (clean(value.title) || sentence).slice(0, 240);
|
||||
const url = clean(value.url).slice(0, 2000);
|
||||
const body = text && url && text.includes(url) ? text : [text, url].filter(Boolean).join('\n\n');
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
const emptyDraft = () => ({ repository: '', title: '', body: '', labelIds: [] });
|
||||
const safeMilestoneId = value => Number.isInteger(Number(value)) && Number(value) > 0 ? Number(value) : null;
|
||||
const safeDueDate = value => /^\d{4}-\d{2}-\d{2}$/.test(String(value || '')) ? String(value) : '';
|
||||
|
||||
function loadStored() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(storageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return {...emptyDraft(), operationId: ''};
|
||||
const draft = {
|
||||
repository: String(parsed.repository || ''),
|
||||
title: String(parsed.title || ''),
|
||||
body: String(parsed.body || ''),
|
||||
labelIds: safeLabelIds(parsed.labelIds),
|
||||
operationId: String(parsed.operationId || '').slice(0, 128),
|
||||
};
|
||||
const milestoneId = safeMilestoneId(parsed.milestoneId);
|
||||
const dueDate = safeDueDate(parsed.dueDate);
|
||||
if (milestoneId !== null) draft.milestoneId = milestoneId;
|
||||
if (dueDate) draft.dueDate = dueDate;
|
||||
return draft;
|
||||
} catch (_error) {
|
||||
return {...emptyDraft(), operationId: ''};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(record) {
|
||||
try { storage.setItem(storageKey, JSON.stringify(record)); }
|
||||
catch (_error) { /* Keep the form as the in-memory fallback. */ }
|
||||
}
|
||||
|
||||
function saveDraft(draft) {
|
||||
const previous = loadStored();
|
||||
const safe = {
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: safeLabelIds(draft?.labelIds),
|
||||
};
|
||||
const milestoneId = safeMilestoneId(draft?.milestoneId);
|
||||
const dueDate = safeDueDate(draft?.dueDate);
|
||||
if (milestoneId !== null) safe.milestoneId = milestoneId;
|
||||
if (dueDate) safe.dueDate = dueDate;
|
||||
const unchanged = ['repository', 'title', 'body', 'milestoneId', 'dueDate']
|
||||
.every(key => (previous[key] || '') === (safe[key] || '')) &&
|
||||
JSON.stringify(previous.labelIds) === JSON.stringify(safe.labelIds);
|
||||
writeStored({...safe, operationId: unchanged ? previous.operationId : ''});
|
||||
return safe;
|
||||
}
|
||||
|
||||
function loadDraft() {
|
||||
const {operationId: _operationId, ...draft} = loadStored();
|
||||
return draft;
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
try { storage.removeItem(storageKey); }
|
||||
catch (_error) { /* Confirmed creation remains authoritative. */ }
|
||||
}
|
||||
|
||||
function pendingSharedContent() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(sharedStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const shared = normalizeSharedContent({title: parsed.title, text: parsed.body});
|
||||
return shared.title || shared.body ? shared : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptSharedContent() {
|
||||
const shared = pendingSharedContent();
|
||||
if (!shared) return loadDraft();
|
||||
const accepted = saveDraft({...loadDraft(), ...shared});
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardSharedContent() {
|
||||
try { storage.removeItem(sharedStorageKey); }
|
||||
catch (_error) { /* The existing issue draft remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageSharedContent(value) {
|
||||
const shared = normalizeSharedContent(value);
|
||||
if (!shared.title && !shared.body) return {status: 'empty'};
|
||||
try { storage.setItem(sharedStorageKey, JSON.stringify(shared)); }
|
||||
catch (_error) { /* The caller can still use the in-page share payload. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptSharedContent();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
return fetchJson('api/v1/repos/' + encoded + '/labels').then(labels =>
|
||||
(Array.isArray(labels) ? labels : []).slice().sort((left, right) => {
|
||||
const leftPriority = priorities.has(String(left?.name || '').toLowerCase());
|
||||
const rightPriority = priorities.has(String(right?.name || '').toLowerCase());
|
||||
return Number(rightPriority) - Number(leftPriority);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function loadMilestones(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + encoded + '/milestones').then(milestones =>
|
||||
Array.isArray(milestones) ? milestones : []
|
||||
);
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
|
||||
.then(payload => ({
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
page: Number(payload?.page) || safePage,
|
||||
total: Math.max(0, Number(payload?.total) || 0),
|
||||
has_more: payload?.has_more === true,
|
||||
}))
|
||||
.finally(() => repositoryPageRequests.delete(safePage));
|
||||
repositoryPageRequests.set(safePage, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function searchRepositories(value) {
|
||||
const query = String(value || '').trim().slice(0, 80);
|
||||
const request = ++repositorySearchRequest;
|
||||
if (query.length < 2) return {status: 'idle', items: []};
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
'api/v1/repositories/search?q=' + encodeURIComponent(query) + '&limit=20'
|
||||
);
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {
|
||||
status: 'ready',
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (request !== repositorySearchRequest) return {status: 'stale', items: []};
|
||||
return {status: 'failed', items: [], error};
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
return { repository, title, key: repository + '\n' + title.toLowerCase() };
|
||||
}
|
||||
|
||||
async function findDuplicates(draft) {
|
||||
const input = duplicateKey(draft);
|
||||
const request = ++duplicateRequest;
|
||||
if (!input.repository || input.title.length < 4) {
|
||||
duplicateState = {status: 'idle', key: input.key, candidates: []};
|
||||
return duplicateState;
|
||||
}
|
||||
try {
|
||||
const payload = await fetchJson('api/v1/search?q=' + encodeURIComponent(input.title) + '&limit=10');
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {
|
||||
status: 'ready',
|
||||
key: input.key,
|
||||
partial: payload?.partial === true,
|
||||
candidates: (Array.isArray(payload?.items) ? payload.items : []).filter(item =>
|
||||
item?.kind === 'issue' && item?.state === 'open' && item?.repository === input.repository
|
||||
).slice(0, 3),
|
||||
};
|
||||
return duplicateState;
|
||||
} catch (error) {
|
||||
if (request !== duplicateRequest) return {status: 'stale', key: input.key, candidates: []};
|
||||
duplicateState = {status: 'failed', key: input.key, candidates: [], error};
|
||||
return duplicateState;
|
||||
}
|
||||
}
|
||||
|
||||
function needsDuplicateAcknowledgement(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
return duplicateState.status === 'ready' && duplicateState.partial !== true && duplicateState.key === key &&
|
||||
duplicateState.candidates.length > 0 && acknowledgedDuplicateKey !== key;
|
||||
}
|
||||
|
||||
function acknowledgeDuplicates(draft) {
|
||||
const {key} = duplicateKey(draft);
|
||||
if (duplicateState.status === 'ready' && duplicateState.key === key && duplicateState.candidates.length) {
|
||||
acknowledgedDuplicateKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
function submit(draft) {
|
||||
if (pending) return pending;
|
||||
const saved = saveDraft(draft);
|
||||
const stored = loadStored();
|
||||
const operationId = stored.operationId || String(createOperationId()).slice(0, 128);
|
||||
writeStored({...saved, operationId});
|
||||
const repository = saved.repository.split('/').map(encodeURIComponent).join('/');
|
||||
pending = fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: saved.title, body: saved.body, label_ids: saved.labelIds,
|
||||
...(saved.milestoneId ? {milestone_id: saved.milestoneId} : {}),
|
||||
...(saved.dueDate ? {due_date: saved.dueDate + 'T23:59:59Z'} : {}),
|
||||
}),
|
||||
}).then(issue => {
|
||||
clearDraft();
|
||||
return issue;
|
||||
}).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage,
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
createIssueCapture.normalizeSharedContent = normalizeSharedContent;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueCapture;
|
||||
@@ -0,0 +1,711 @@
|
||||
:root { color-scheme: dark; --bg:#050c15; --panel:#0b1526; --line:#1b2d45; --text:#e5e7eb; --accent:#60a5fa; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; overflow-x: hidden; }
|
||||
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: .35; }
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
|
||||
.notification-undo[hidden] { display:none; }
|
||||
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
.active-devices-panel { box-sizing:border-box; width:min(560px,100%); height:100%; overflow:auto; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.active-devices-header h2, .active-devices-header p { margin-top:0; }
|
||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
||||
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
|
||||
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.device-setup-sheet[hidden] { display:none; }
|
||||
.device-setup-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.device-setup-header h2, .device-setup-header p { margin-top:0; }
|
||||
.device-setup-header button, .device-setup-action { min-height:44px; }
|
||||
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
|
||||
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.device-readiness-actions button { min-height:44px; min-width:0; }
|
||||
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
|
||||
.active-device-current { color:#55d6be; font-weight:700; }
|
||||
.enrolled-passkeys { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.enrolled-passkeys-header h3, .enrolled-passkeys-header p { margin:0 0 6px; }
|
||||
.enrolled-passkeys-list { display:grid; gap:10px; margin-top:12px; }
|
||||
.enrolled-passkey { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.enrolled-passkey strong, .enrolled-passkey span { display:block; overflow-wrap:anywhere; }
|
||||
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
|
||||
.security-activity-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
||||
.security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||
#load-more-security-activity { width:100%; min-height:44px; }
|
||||
button:hover { filter: brightness(1.15); }
|
||||
.issue-comment { min-width:0; overflow-wrap:anywhere; }
|
||||
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
|
||||
.comment-owned-actions button { min-height:44px; min-width:72px; }
|
||||
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
|
||||
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
|
||||
.panel > summary { cursor: pointer; list-style-position: inside; }
|
||||
.panel > summary h2 { display: inline-block; margin-left: 4px; }
|
||||
.panel[open] > summary { margin-bottom: 8px; }
|
||||
aside, section { position: relative; z-index: 1; }
|
||||
main { position: relative; z-index: 1; display: grid; grid-template-columns: 300px 1fr 340px; gap: 14px; padding: 14px; }
|
||||
@media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
|
||||
.sidebar { display:flex; flex-direction:column; gap: 12px; }
|
||||
.stack { display:flex; flex-direction:column; gap:10px; }
|
||||
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
|
||||
a { color: #60a5fa; text-decoration: none; }
|
||||
.muted { color: #94a3b8; }
|
||||
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
|
||||
.empty-work-start[hidden] { display:none; }
|
||||
.empty-work-start p { margin:6px 0 0; }
|
||||
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
|
||||
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
|
||||
@media (max-width:420px) {
|
||||
.empty-work-start-actions { grid-template-columns:1fr; }
|
||||
}
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
|
||||
.kv .label { color: #94a3b8; }
|
||||
.kv .value { color: #e5e7eb; }
|
||||
.suggestion { padding: 10px; border-radius: 12px; border: 1px solid #1b2d45; background: #0f1d33; margin-bottom: 8px; animation: fadein .35s ease; }
|
||||
.suggestion.high { border-color: #fca5a5; background: #2a1216; }
|
||||
.suggestion.medium { border-color: #fcd34d; background: #2a2012; }
|
||||
.suggestion.low { border-color: #d1fae5; background: #122a1e; }
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items:center; }
|
||||
.pill { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; font-size: 12px; }
|
||||
canvas { display: block; width: 100%; height: 260px; background: linear-gradient(180deg,#0a1525,#060c16); border: 1px solid #1f3a5f; border-radius: 10px; }
|
||||
pre { margin: 0; padding: 8px; background: #0b1220; color: #dbe7f2; border-radius: 8px; overflow: auto; max-height: 220px; font-size: 12px; border:1px solid #1f3a5f }
|
||||
input[type=\"text\"], textarea { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
textarea { resize: vertical; min-height: 120px; }
|
||||
#cmd-palette { position: fixed; left: 50%; top: 10%; transform: translateX(-50%); width: min(900px, 94vw); background: rgba(11,21,38,.96); border: 1px solid #2a496e; border-radius: 12px; box-shadow: 0 20px 70px rgba(0,0,0,.55); padding: 10px; z-index: 30; display: none; backdrop-filter: blur(12px); }
|
||||
#cmd-palette.open { display: block; }
|
||||
.cmd-palette-header { display:none; align-items:center; justify-content:space-between; gap:10px; }
|
||||
#cmd-results { margin-top:8px; max-height:min(65vh,520px); overflow-y:auto; }
|
||||
.cmd-item { padding: 10px; min-height:44px; cursor: pointer; border-radius: 10px; color:#e5e7eb; display:flex; gap:10px; align-items:center; justify-content:space-between; }
|
||||
.cmd-item:hover, .cmd-item.selected { background: #10233a; outline:1px solid #31577f; }
|
||||
.cmd-meta { color:#93a4b8; font-size:12px; text-align:right; }
|
||||
.cmd-status { padding:10px; color:#93a4b8; font-size:13px; }
|
||||
.cmd-group { padding:8px 10px 3px; color:#60a5fa; font-size:11px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
|
||||
#whiteboard-modal, #markdown-modal { position: fixed; inset: 0; background: rgba(5,12,21,.55); display: none; align-items: center; justify-content: center; z-index: 40; backdrop-filter: blur(6px); }
|
||||
#whiteboard-modal.open, #markdown-modal.open { display: flex; }
|
||||
.modal { background: #0b1526; border: 1px solid #2a496e; border-radius: 14px; padding: 14px; width: min(1100px, 94vw); max-height: 92vh; overflow: auto; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.badge { font-size: 11px; padding: 4px 8px; border-radius: 999px; background:#0f2237; color:#9ab3d4; border:1px solid #1f3a5f; }
|
||||
.small { font-size: 12px; color: #94a3b8; }
|
||||
.status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 8px #22c55e; }
|
||||
.offline-status { position:relative; z-index:19; padding:10px 16px; border-bottom:1px solid #f59e0b; background:#35210b; color:#fde68a; font-size:13px; text-align:center; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
.offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.offline-today-readiness[hidden] { display:none; }
|
||||
.offline-today-readiness button { min-height:44px; }
|
||||
.install-app-card { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding:10px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.install-app-card[hidden] { display:none; }
|
||||
.install-app-card p { margin:0; flex:1 1 240px; }
|
||||
.install-app-card button { min-height:44px; }
|
||||
.widget { border: 1px solid #1b2d45; border-radius: 12px; padding: 10px; background: linear-gradient(180deg,#0f1d33,#0b1526); }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.plan-today-sheet { position:fixed; inset:0; z-index:75; display:flex; justify-content:flex-end; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.plan-today-sheet[hidden] { display:none; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
|
||||
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
|
||||
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
|
||||
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
|
||||
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
|
||||
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
|
||||
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
|
||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
|
||||
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
|
||||
.plan-preview-actions[hidden] { display:none; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-interruption-sheet[hidden] { display:none; }
|
||||
.today-interruption-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
|
||||
.today-interruption-actions button { min-height:44px; width:100%; }
|
||||
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-recap-sheet[hidden] { display:none; }
|
||||
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-recap-header h2 { margin:.2rem 0; }
|
||||
.today-recap-header button { min-height:44px; min-width:44px; }
|
||||
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
|
||||
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
|
||||
.today-recap-row strong { overflow-wrap:anywhere; }
|
||||
.today-recap-row input { width:6rem; min-height:44px; }
|
||||
.today-recap-context { display:block; overflow-wrap:anywhere; }
|
||||
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
|
||||
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
|
||||
.today-recap-log input { width:24px; min-height:24px; }
|
||||
.today-recap-log-success { color:#86efac; font-weight:700; }
|
||||
.today-recap-log-verify { color:#fde68a; font-weight:700; }
|
||||
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
|
||||
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.today-recap-actions button { min-height:44px; width:100%; }
|
||||
@media (max-width:420px) {
|
||||
.today-interruption-actions { grid-template-columns:1fr; }
|
||||
.today-recap-actions { grid-template-columns:1fr; }
|
||||
.today-recap-row { grid-template-columns:1fr; }
|
||||
.today-recap-row input { width:min(100%,8rem); }
|
||||
.today-recap-variance { grid-column:1; }
|
||||
}
|
||||
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.today-readiness-sheet[hidden] { display:none; }
|
||||
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.today-readiness-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.today-readiness-header h2 { margin:.2rem 0; }
|
||||
.today-readiness-header button, .today-readiness-actions button { min-height:44px; }
|
||||
.today-readiness-item { min-width:0; overflow-wrap:anywhere; margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.today-readiness-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-settings { width:100%; }
|
||||
.work-settings > summary { display:none; }
|
||||
.work-settings-panel { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
.delivery-center h3, .delivery-center p { margin:0; }
|
||||
.delivery-center button { min-height:44px; flex:0 0 auto; }
|
||||
.delivery-attempt { color:#fbbf24; overflow-wrap:anywhere; }
|
||||
.draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; }
|
||||
.draft-section > h3 { grid-column:1/-1; margin:8px 0 0; }
|
||||
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
|
||||
.create-issue-actions #create-issue-status { grid-column:1/-1; }
|
||||
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
|
||||
.draft-filing-session[hidden] { display:none; }
|
||||
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-filing-session-actions button { min-height:44px; width:100%; }
|
||||
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
|
||||
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.agenda-replan-launch p { margin:4px 0 0; }
|
||||
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
|
||||
.agenda-replan-actions button { min-height:44px; width:100%; }
|
||||
.agenda-replan-actions label { grid-column:1/-1; }
|
||||
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
|
||||
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.protect-today p { margin:4px 0; }
|
||||
.protect-today button { min-height:44px; flex:0 0 auto; }
|
||||
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
|
||||
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
|
||||
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
|
||||
.selection-scope-actions button { min-height:44px; max-width:100%; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
.card-planning { min-width:0; }
|
||||
.card-planning > summary { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:grid; }
|
||||
.card-planning-actions { display:grid; gap:8px; min-width:0; }
|
||||
.detail-defer { grid-column:1/-1; max-width:100%; }
|
||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||
.later-picker[hidden] { display:none; }
|
||||
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.later-picker-header h2 { margin:0; }
|
||||
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
|
||||
.later-picker-error { min-height:1.4em; color:#fca5a5; }
|
||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||
.load-more-notifications { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-notifications[hidden] { display:none; }
|
||||
.load-more-work { min-height:44px; width:100%; margin-top:10px; }
|
||||
.load-more-work[hidden] { display:none; }
|
||||
.retry-work-route { min-height:44px; width:100%; margin-top:10px; }
|
||||
.retry-work-route[hidden] { display:none; }
|
||||
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.my-work-bulk button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.my-work-bulk-actions button { min-height:44px; width:100%; }
|
||||
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.review-sheet.open { display:flex; }
|
||||
.review-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.review-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.review-sheet-body { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-file, .review-history { padding:8px 0; border-bottom:1px solid #1b2d45; overflow-wrap:anywhere; }
|
||||
.review-file-toggle { min-height:44px; width:100%; display:flex; align-items:flex-start; justify-content:space-between; gap:8px; text-align:left; }
|
||||
.review-file-toggle strong { overflow-wrap:anywhere; }
|
||||
.review-file.reviewed { opacity:.72; }
|
||||
.review-file.reviewed .review-file-toggle { border-color:#22c55e; }
|
||||
.review-mark { min-height:44px; width:100%; margin-top:8px; }
|
||||
.review-mark[aria-pressed="true"] { border-color:#22c55e; background:#123c2a; }
|
||||
.review-note { min-height:88px; margin-top:6px; }
|
||||
.review-feedback { display:grid; gap:8px; }
|
||||
.review-feedback label { display:grid; gap:6px; }
|
||||
.review-feedback select { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.review-handoff { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.review-handoff button { min-height:44px; }
|
||||
.review-handoff-link { min-height:44px; display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:8px; color:#bfdbfe; font-weight:700; text-decoration:none; }
|
||||
.review-handoff-link[hidden] { display:none; }
|
||||
.review-merge-continuation { position:sticky; bottom:0; z-index:5; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); }
|
||||
#continue-review-to-merge { min-height:44px; width:100%; }
|
||||
#continue-review-to-merge[hidden] { display:none; }
|
||||
.review-copy-fallback { min-height:140px; }
|
||||
.review-progress-actions { position:sticky; bottom:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:8px -4px 0; padding:10px 4px; background:rgba(11,21,38,.96); border-top:1px solid #2a496e; }
|
||||
.review-progress-actions button { min-height:44px; max-width:100%; }
|
||||
.review-display-tools { display:flex; align-items:center; gap:10px; margin-bottom:8px; }
|
||||
.review-display-tools button { min-height:44px; flex:0 0 auto; }
|
||||
.review-display-tools button[aria-pressed="true"] { color:#bfdbfe; background:#17365a; outline:1px solid #60a5fa; }
|
||||
.review-diff { overflow-x:auto; max-width:100%; margin-top:8px; white-space:pre; }
|
||||
.review-diff-line { display:grid; grid-template-columns:max-content minmax(max-content,1fr); align-items:stretch; min-width:max-content; }
|
||||
.review-line-numbers { position:sticky; left:0; z-index:1; display:grid; grid-template-columns:4ch 4ch; align-self:stretch; background:#0b1526; color:#7694b6; user-select:none; }
|
||||
.review-line-number { padding:8px 4px; text-align:right; border-right:1px solid #244363; }
|
||||
.review-line-code { min-width:0; padding:8px; white-space:pre; overflow-wrap:anywhere; }
|
||||
.review-inline-target { min-height:44px; width:100%; padding:0; border:0; border-radius:0; text-align:left; font:inherit; white-space:normal; }
|
||||
.wrap-lines .review-diff { overflow-x:hidden; white-space:normal; }
|
||||
.wrap-lines .review-diff-line { grid-template-columns:max-content minmax(0,1fr); min-width:0; }
|
||||
.wrap-lines .review-line-code { white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.review-inline-target.has-draft { box-shadow:inset 4px 0 #fbbf24; }
|
||||
.review-inline-composer { position:sticky; bottom:0; z-index:4; display:grid; gap:8px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #60a5fa; border-radius:10px; background:#0b1526; }
|
||||
.review-inline-composer[hidden] { display:none; }
|
||||
.review-inline-composer textarea { min-height:96px; width:100%; }
|
||||
.review-inline-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.review-inline-actions button { min-height:44px; }
|
||||
.review-diff-line.hunk { color:#93c5fd; }
|
||||
.review-diff-line.added { color:#86efac; background:rgba(34,197,94,.09); }
|
||||
.review-diff-line.removed { color:#fca5a5; background:rgba(239,68,68,.09); }
|
||||
.review-diff-note, .review-diff-empty { display:block; padding:8px; color:#fcd34d; white-space:normal; }
|
||||
.review-action { min-height:44px; }
|
||||
.review-retry { min-height:44px; margin-top:10px; }
|
||||
.ci-checks { max-width:100%; margin:10px 0; border:1px solid #2a496e; border-radius:10px; overflow:hidden; }
|
||||
.ci-checks > summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 10px; cursor:pointer; }
|
||||
.ci-checks > summary .small { min-width:0; overflow-wrap:anywhere; text-align:right; }
|
||||
.ci-check-refresh { width:100%; min-height:44px; margin-bottom:6px; }
|
||||
.ci-check-list { display:grid; gap:6px; min-width:0; padding:0 8px 8px; }
|
||||
.ci-check { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:8px; border:1px solid #203a5c; border-radius:8px; }
|
||||
.ci-check-failure, .ci-check-error { border-color:#dc2626; background:rgba(127,29,29,.22); }
|
||||
.ci-check-pending { border-color:#d97706; background:rgba(120,53,15,.2); }
|
||||
.ci-check-success { border-color:#15803d; }
|
||||
.ci-check-copy { min-width:0; display:grid; gap:3px; overflow-wrap:anywhere; }
|
||||
.ci-check-copy strong, .ci-check-copy span { min-width:0; overflow-wrap:anywhere; }
|
||||
.ci-check-link { min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 10px; border:1px solid #60a5fa; border-radius:8px; white-space:nowrap; }
|
||||
.update-sheet { position:fixed; inset:0; z-index:55; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.update-sheet.open { display:flex; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
.update-reply button { min-height:44px; width:100%; }
|
||||
.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-reply-actions button { min-height:44px; width:100%; }
|
||||
.update-reply .issue-attachment-preview { width:100%; min-width:0; }
|
||||
@media (max-width:390px) {
|
||||
.update-reply-actions { grid-template-columns:1fr; }
|
||||
.update-reply .issue-attachment-preview { grid-template-columns:56px minmax(0,1fr); padding:8px; }
|
||||
.update-reply .issue-attachment-preview img { width:56px; height:56px; }
|
||||
}
|
||||
.update-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.update-ownership-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.issue-sheet.open { display:flex; }
|
||||
.issue-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.issue-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.issue-sheet-header button { min-height:44px; }
|
||||
.issue-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.issue-blockers { max-width:100%; overflow-x:hidden; margin:16px 0; padding:12px; border:1px solid #b45309; border-radius:12px; background:#291b0c; }
|
||||
.issue-blockers h2 { margin-top:0; }
|
||||
.issue-blocker-list { display:grid; gap:8px; }
|
||||
.issue-blocker { min-width:0; overflow-wrap:anywhere; display:grid; gap:4px; padding:10px; border:1px solid #92400e; border-radius:10px; color:#fef3c7; text-decoration:none; }
|
||||
.issue-blocker:hover, .issue-blocker:focus-visible { border-color:#f59e0b; }
|
||||
.issue-blocker-row { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:start; }
|
||||
.issue-blocker-row .issue-blocker { min-width:0; }
|
||||
.issue-blocker-remove { min-height:44px; white-space:normal; }
|
||||
.issue-blocker-manager { display:grid; gap:8px; margin-top:10px; min-width:0; }
|
||||
.issue-blocker-manager :is(input,button) { min-height:44px; width:100%; max-width:100%; box-sizing:border-box; }
|
||||
.issue-blocker-manager [role="listbox"] { display:grid; gap:6px; max-height:240px; overflow:auto; }
|
||||
.issue-blocker-manager [role="option"] { text-align:left; overflow-wrap:anywhere; }
|
||||
#manage-issue-blockers, #start-unblocked-issue { min-height:44px; width:100%; margin-top:10px; }
|
||||
.today-readiness-blocker { width:100%; min-height:44px; text-align:left; background:#291b0c; }
|
||||
.issue-planning { max-width:100%; margin-top:16px; border:1px solid #2a496e; border-radius:12px; padding:0 12px 12px; overflow-x:hidden; }
|
||||
.issue-planning > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:700; }
|
||||
.issue-planning-retry { min-height:44px; width:100%; }
|
||||
#edit-issue-content { min-height:44px; width:100%; }
|
||||
.issue-edit-form { display:grid; gap:8px; max-width:100%; margin:12px 0; }
|
||||
.issue-edit-form label { display:grid; gap:6px; min-width:0; }
|
||||
.issue-edit-form input, .issue-edit-form textarea { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-edit-form textarea { min-height:132px; resize:vertical; }
|
||||
.issue-edit-form input, .issue-edit-form textarea, .issue-edit-form button { min-height:44px; }
|
||||
.issue-edit-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-comment { padding:10px 0; border-bottom:1px solid #1b2d45; }
|
||||
.issue-comment-composer { display:grid; gap:8px; margin-top:16px; }
|
||||
.issue-comment-composer button { min-height:44px; width:100%; }
|
||||
.visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.issue-attachment-controls { display:flex; max-width:100%; }
|
||||
.issue-attachment-trigger { min-height:44px; display:inline-flex; align-items:center; justify-content:center; padding:8px 12px; border:1px solid #60a5fa; border-radius:10px; color:#dbeafe; font-weight:700; cursor:pointer; }
|
||||
.issue-attachment-preview { display:grid; grid-template-columns:64px minmax(0,1fr); gap:8px 12px; align-items:center; max-width:100%; overflow:hidden; padding:10px; border:1px solid #315781; border-radius:10px; background:#101f34; }
|
||||
.issue-attachment-preview[hidden] { display:none; }
|
||||
.issue-attachment-preview img { grid-row:span 2; width:64px; height:64px; object-fit:cover; border-radius:8px; }
|
||||
.issue-attachment-preview .small { min-width:0; overflow-wrap:anywhere; }
|
||||
.issue-attachment-preview button { min-height:44px; width:auto; justify-self:start; }
|
||||
.mention-options { display:grid; max-width:100%; max-height:220px; overflow:auto; border:1px solid #315781; border-radius:10px; background:#101f34; box-shadow:0 10px 28px rgba(0,0,0,.35); }
|
||||
.mention-options[hidden] { display:none; }
|
||||
.mention-option { min-height:44px; max-width:100%; overflow:hidden; padding:9px 12px; border:0; border-bottom:1px solid #203a5c; border-radius:0; text-align:left; text-overflow:ellipsis; white-space:nowrap; background:#101f34; color:#dbeafe; }
|
||||
.mention-option:last-child { border-bottom:0; }
|
||||
.mention-option[aria-selected="true"], .mention-option:hover { background:#17365a; color:#fff; }
|
||||
.mention-status:empty { display:none; }
|
||||
.comment-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.comment-actions button { min-height:44px; width:100%; }
|
||||
.issue-label-editor { max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(180px,100%),1fr)); gap:8px; max-width:100%; }
|
||||
.issue-label-option { min-height:44px; max-width:100%; display:flex; align-items:center; gap:10px; padding:8px; border:1px solid #2a496e; border-radius:10px; overflow-wrap:anywhere; }
|
||||
.issue-label-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
.issue-label-editor button { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-due-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-due-editor input { box-sizing:border-box; width:100%; max-width:100%; }
|
||||
.issue-due-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.issue-due-editor input, .issue-due-editor button { min-height:44px; }
|
||||
.issue-milestone-editor { display:grid; gap:8px; max-width:100%; margin:14px 0; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-milestone-editor select { width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px; }
|
||||
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
|
||||
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
|
||||
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
|
||||
.issue-handoff select, .issue-handoff button { min-height:44px; }
|
||||
.pull-ownership select, .pull-ownership button { min-height:44px; }
|
||||
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.new-issue { min-height:44px; }
|
||||
.find-work-action { min-height:44px; }
|
||||
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.start-work-session { min-height:44px; }
|
||||
.resume-today-session, .end-today-session { min-height:44px; }
|
||||
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.work-session-nav[hidden] { display:none; }
|
||||
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
|
||||
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
|
||||
.work-session-nav button { min-height:44px; width:100%; }
|
||||
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.find-work-sheet.open { display:flex; }
|
||||
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.find-work-header-actions { display:flex; gap:8px; }
|
||||
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
|
||||
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
|
||||
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.find-work-search input, .find-work-search button { min-height:44px; }
|
||||
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
|
||||
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
|
||||
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
|
||||
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
|
||||
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
|
||||
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
|
||||
#clear-find-work-filters { min-height:44px; width:100%; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
|
||||
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
|
||||
.find-work-select input { width:22px; height:22px; }
|
||||
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
|
||||
.find-work-batch-actions[hidden] { display:none; }
|
||||
.find-work-batch-actions span { grid-column:1 / -1; }
|
||||
.find-work-batch-actions button { min-height:44px; }
|
||||
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
|
||||
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||
.find-work-estimate-review[hidden] { display:none; }
|
||||
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
|
||||
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
|
||||
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
|
||||
.find-work-estimate-review input { min-height:44px; width:76px; }
|
||||
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||
.find-work-estimate-actions button { min-height:44px; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.search-preview { position:fixed; inset:0; z-index:60; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.search-preview.open { display:flex; }
|
||||
.search-preview-panel { box-sizing:border-box; width:min(560px,100%); height:100dvh; overflow:auto; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||
.search-preview-header { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
.markdown-content > :last-child { margin-bottom:0; }
|
||||
.markdown-content pre { max-width:100%; overflow-x:auto; padding:10px; border-radius:8px; background:#07101d; white-space:pre; -webkit-overflow-scrolling:touch; }
|
||||
.markdown-content code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
|
||||
.markdown-content :not(pre) > code { padding:2px 4px; border-radius:4px; background:#07101d; }
|
||||
.markdown-content blockquote { margin:10px 0; padding-left:12px; border-left:3px solid #60a5fa; color:#b9c9dd; }
|
||||
.markdown-content ul { padding-left:24px; }
|
||||
.markdown-content .task-list { padding-left:0; list-style:none; }
|
||||
.markdown-content .task-list-item { display:flex; gap:8px; align-items:flex-start; }
|
||||
.markdown-content .task-list-item input { flex:0 0 auto; margin-top:3px; }
|
||||
.markdown-content a { min-height:44px; display:inline-flex; align-items:center; max-width:100%; overflow-wrap:anywhere; }
|
||||
@media(max-width:320px) { .find-work-panel { padding:12px; overflow-x:hidden; } .find-work-card { min-width:0; } .my-work-actions { width:100%; } .my-work-actions button { flex:1 1 100%; } }
|
||||
.create-issue-sheet { position:fixed; inset:0; z-index:57; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
.create-issue-repository-picker input { min-width:0; min-height:44px; width:100%; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
#create-issue-repository-results { display:grid; max-height:min(36dvh,280px); overflow:auto; border:1px solid #2a496e; border-radius:10px; }
|
||||
#create-issue-repository-results[hidden] { display:none; }
|
||||
.create-issue-repository-result { min-height:44px; min-width:0; padding:10px 12px; overflow-wrap:anywhere; text-align:left; border:0; border-bottom:1px solid #1f3a5f; border-radius:0; background:#10213a; color:#e5e7eb; }
|
||||
.create-issue-repository-result:last-child { border-bottom:0; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
.create-issue-label-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; }
|
||||
.create-issue-label-option { min-height:44px; display:flex !important; grid-template-columns:auto 1fr !important; align-items:center; gap:8px; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.create-issue-label-option input { width:20px; height:20px; margin:0; }
|
||||
.create-issue-duplicates { display:grid; gap:8px; min-width:0; padding:12px; border:1px solid #f59e0b; border-radius:10px; background:#2b210f; }
|
||||
.create-issue-duplicates[hidden] { display:none; }
|
||||
.create-issue-duplicate-list { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-duplicate-card { display:grid; gap:4px; min-width:0; padding:10px; border:1px solid #6b5220; border-radius:10px; background:#171d29; overflow-wrap:anywhere; }
|
||||
.create-issue-duplicate-card a, #create-issue-anyway { display:flex; align-items:center; min-height:44px; max-width:100%; }
|
||||
.create-issue-duplicate-card a { color:#93c5fd; }
|
||||
.create-issue-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.shared-content-conflict { display:grid; gap:8px; padding:12px; border:1px solid #8b5cf6; border-radius:10px; background:#16142b; }
|
||||
.shared-content-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.shared-content-actions button { min-height:44px; }
|
||||
.pull-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.pull-sheet.open { display:flex; }
|
||||
.pull-sheet-panel { width:min(560px,100%); height:100dvh; overflow:auto; padding:18px; padding-bottom:calc(90px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.pull-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.pull-sheet-header button, .pull-sheet-actions button, .pull-sheet-actions a, .pull-comment-composer button { min-height:44px; }
|
||||
.pull-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.pull-file, .pull-comment-card { margin:8px 0; padding:10px; border:1px solid #203a5c; border-radius:10px; }
|
||||
.conversation-more { min-height:44px; width:100%; margin:8px 0; }
|
||||
.pull-file-toggle, .pull-review-file { min-height:44px; width:100%; }
|
||||
.pull-file-toggle { display:flex; justify-content:space-between; align-items:center; gap:8px; text-align:left; }
|
||||
.pull-review-file { margin-top:8px; }
|
||||
.pull-diff { overflow-x:auto; margin:8px 0; padding:10px; background:#07111f; border-radius:8px; font-size:12px; }
|
||||
.pull-diff-line { display:block; width:max-content; min-width:100%; }
|
||||
.pull-diff-line.added { color:#86efac; background:#123422; }
|
||||
.pull-diff-line.removed { color:#fca5a5; background:#3b161b; }
|
||||
.pull-diff-line.hunk, .pull-diff-note { color:#93c5fd; }
|
||||
.pull-diff-empty { margin:8px 0; padding:10px; border:1px dashed #4e6b8a; border-radius:8px; }
|
||||
.pull-review-tools { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; }
|
||||
.pull-review-tools button { min-height:44px; }
|
||||
.pull-review { margin-top:14px; overflow:hidden; border:1px solid #2a496e; border-radius:10px; padding:0 10px 10px; }
|
||||
.pull-review summary { min-height:44px; display:flex; align-items:center; cursor:pointer; }
|
||||
.pull-review summary h2 { margin:0; font-size:16px; }
|
||||
.pull-review > #merge-pull { min-height:44px; width:100%; margin-top:10px; }
|
||||
.pull-comment-composer textarea { width:100%; min-height:110px; resize:vertical; }
|
||||
.pull-sheet-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.pull-sheet-actions a { display:grid; place-items:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
.app-menu { position:relative; margin-left:0; }
|
||||
.app-menu > summary { min-width:44px; min-height:44px; display:grid; place-items:center; cursor:pointer; border:1px solid #2a496e; border-radius:10px; list-style:none; }
|
||||
.app-menu > summary::-webkit-details-marker { display:none; }
|
||||
.app-menu:not([open]) > .app-menu-panel { display:none; }
|
||||
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
|
||||
.app-menu-panel button { min-height:44px; width:100%; }
|
||||
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.device-setup-step { grid-template-columns:1fr; }
|
||||
.device-setup-action { width:100%; }
|
||||
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
|
||||
.my-work { margin:0; }
|
||||
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
|
||||
.my-work-header { align-items:flex-start; }
|
||||
.my-work-actions { display:none; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
.card-planning:not([open]) > .card-planning-actions { display:none; }
|
||||
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
|
||||
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
.review-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.update-sheet-panel { width:100%; border-left:0; padding:14px; overflow-x:hidden; }
|
||||
.issue-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.issue-blocker-row { grid-template-columns:1fr; }
|
||||
.issue-blocker-remove { width:100%; }
|
||||
.search-preview-panel { width:100%; border-left:0; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); overflow-x:hidden; }
|
||||
#cmd-palette { left:0; top:var(--search-viewport-top,0px); transform:none; width:100%; height:var(--search-viewport-height,100dvh); border:0; border-radius:0; padding:12px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); z-index:46; }
|
||||
#cmd-palette.open { display:flex; flex-direction:column; }
|
||||
.cmd-palette-header { display:flex; flex:0 0 auto; min-height:44px; }
|
||||
#close-command-palette { min-height:44px; }
|
||||
#cmd-input { flex:0 0 auto; min-height:44px; }
|
||||
#cmd-results { flex:1; min-height:0; overflow-y:auto; max-height:none; overscroll-behavior:contain; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.create-issue-panel { width:100%; border-left:0; padding:14px; }
|
||||
.pull-sheet-panel { width:100%; border-left:0; padding:14px; }
|
||||
.create-issue-panel.composer-keyboard-active,
|
||||
.issue-sheet-panel.composer-keyboard-active,
|
||||
.pull-sheet-panel.composer-keyboard-active,
|
||||
.review-sheet-panel.composer-keyboard-active,
|
||||
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
|
||||
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
|
||||
.composer-keyboard-active .create-issue-form,
|
||||
.composer-keyboard-active .issue-comment-composer,
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .review-inline-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-queue-list [data-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
|
||||
.mobile-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
function createDetailDefer({
|
||||
laterWork,
|
||||
session,
|
||||
continueSession = null,
|
||||
close,
|
||||
refresh,
|
||||
focus,
|
||||
announce,
|
||||
formatTime = value => new Date(value).toLocaleString(),
|
||||
}) {
|
||||
return {
|
||||
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
|
||||
if (!item) return false;
|
||||
const result = laterWork.defer(item, until);
|
||||
if (result !== 'deferred') {
|
||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (continueSession && session.checkpointed?.(item)) {
|
||||
if (continueSession(item) === true) return true;
|
||||
laterWork.restore(item);
|
||||
refresh();
|
||||
announce('Could not remove this item from Today, so it was restored from Later.');
|
||||
return false;
|
||||
}
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
if (!inSession && restoreFocus) focus();
|
||||
return true;
|
||||
},
|
||||
defer(item, preset) {
|
||||
return this.deferUntil(item, laterWork.presetUntil(preset));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDetailDefer;
|
||||
@@ -0,0 +1,65 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
@@ -0,0 +1,92 @@
|
||||
function createDraftFilingSession({list}) {
|
||||
let orderedIds = [];
|
||||
let currentId = '';
|
||||
|
||||
function visibleIds() {
|
||||
const available = new Set(list().map(item => item.id));
|
||||
orderedIds = orderedIds.filter(id => available.has(id));
|
||||
for (const item of list()) {
|
||||
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
|
||||
}
|
||||
return orderedIds;
|
||||
}
|
||||
|
||||
function state() {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) {
|
||||
currentId = '';
|
||||
return null;
|
||||
}
|
||||
if (!ids.includes(currentId)) currentId = ids[0];
|
||||
const index = ids.indexOf(currentId);
|
||||
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
|
||||
}
|
||||
|
||||
function start(id) {
|
||||
orderedIds = list().map(item => item.id);
|
||||
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
|
||||
currentId = id;
|
||||
return state();
|
||||
}
|
||||
|
||||
function move(offset) {
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
const index = Math.max(0, ids.indexOf(currentId));
|
||||
currentId = ids[(index + offset + ids.length) % ids.length];
|
||||
return state();
|
||||
}
|
||||
|
||||
function removeCurrentAndNext(removedId) {
|
||||
const previousIds = orderedIds.slice();
|
||||
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
|
||||
orderedIds = previousIds.filter(id => id !== removedId);
|
||||
const ids = visibleIds();
|
||||
if (!ids.length) return state();
|
||||
currentId = ids[Math.min(removedIndex, ids.length - 1)];
|
||||
return state();
|
||||
}
|
||||
|
||||
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
|
||||
session.attach = (qs, dependencies) => {
|
||||
const openCapture = async captureId => {
|
||||
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
|
||||
dependencies.issueCapture.saveDraft(resumed);
|
||||
dependencies.setResumedId(captureId);
|
||||
await dependencies.openSheet();
|
||||
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
|
||||
dependencies.setFilingMode(true);
|
||||
session.render();
|
||||
};
|
||||
return Object.assign(session, {
|
||||
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
|
||||
render() {
|
||||
const state = session.current();
|
||||
qs('#draft-filing-session').hidden = !state;
|
||||
if (!state) return;
|
||||
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
|
||||
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
|
||||
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
|
||||
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
|
||||
},
|
||||
bind() {
|
||||
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
|
||||
qs(selector).addEventListener('click', async () => {
|
||||
const state = session[method]();
|
||||
if (state) await openCapture(state.id);
|
||||
})
|
||||
);
|
||||
},
|
||||
async advance(completedId) {
|
||||
const state = session.removeCurrentAndNext(completedId);
|
||||
if (state) { await openCapture(state.id); return true; }
|
||||
qs('#draft-filing-session').hidden = true;
|
||||
qs('#submit-new-issue').textContent = 'Create & assign to me';
|
||||
return false;
|
||||
},
|
||||
});
|
||||
};
|
||||
return session;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
|
||||
@@ -0,0 +1,257 @@
|
||||
function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Date.now() }) {
|
||||
const indexKey = 'stackchain.draft-index.v1';
|
||||
|
||||
function readIndex() {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(indexKey) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) { return {}; }
|
||||
}
|
||||
|
||||
function writeIndex(index) {
|
||||
try { storage?.setItem(indexKey, JSON.stringify(index)); }
|
||||
catch (_error) { /* Draft discovery remains available without metadata. */ }
|
||||
}
|
||||
|
||||
function keys() {
|
||||
try {
|
||||
return Array.from({ length: Number(storage?.length || 0) }, (_, index) => storage.key(index))
|
||||
.filter(Boolean);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function textPreview(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
||||
}
|
||||
|
||||
function parseTarget(value) {
|
||||
const match = String(value || '').match(/^(.+)#(\d+)$/);
|
||||
return match ? { repository: match[1], number: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
function parse(key, raw) {
|
||||
let match;
|
||||
if (key === 'stackchain.issue-capture.v1') {
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!preview && !value?.repository) return null;
|
||||
return {
|
||||
kind: 'new-issue', label: 'New issue', repository: String(value?.repository || ''),
|
||||
title: textPreview(value?.title) || 'Untitled new issue', preview,
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.(issue|pull)-comment\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[2]);
|
||||
const preview = textPreview(raw);
|
||||
if (!target || !preview) return null;
|
||||
const routeKind = match[1] === 'issue' ? 'issue' : 'pull';
|
||||
return {
|
||||
kind: routeKind + '-comment', label: routeKind === 'issue' ? 'Issue comment' : 'PR comment',
|
||||
...target, title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: routeKind, ...target },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.issue-content\.v1:(.+#\d+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const preview = textPreview([value?.title, value?.body].filter(Boolean).join(' — '));
|
||||
if (!target || !preview || typeof value?.expectedUpdatedAt !== 'string') return null;
|
||||
return {
|
||||
kind: 'issue-edit', label: 'Issue edit', ...target,
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'issue', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
match = key.match(/^stackchain\.update-reply\.v1\.(\d+)$/);
|
||||
if (match) {
|
||||
const preview = textPreview(raw);
|
||||
if (!preview) return null;
|
||||
const notificationId = Number(match[1]);
|
||||
return {
|
||||
kind: 'update-reply', label: 'Update reply', notification_id: notificationId,
|
||||
title: 'Update #' + notificationId, preview,
|
||||
route: { kind: 'update', notification_id: notificationId },
|
||||
};
|
||||
}
|
||||
match = key.match(/^stackchain\.review-draft\.v1:(.+#\d+)@([^:]+)$/);
|
||||
if (match) {
|
||||
const target = parseTarget(match[1]);
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
const notes = Object.values(value?.notes || {});
|
||||
const comments = Array.isArray(value?.comments) ? value.comments.map(item => item?.body) : [];
|
||||
const preview = textPreview([value?.summary, ...notes, ...comments].filter(Boolean).join(' — '));
|
||||
if (!target || !preview) return null;
|
||||
return {
|
||||
kind: 'review', label: 'PR review', ...target, head_sha: match[2],
|
||||
title: target.repository + '#' + target.number, preview,
|
||||
route: { kind: 'review', ...target },
|
||||
};
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.repository === 'string' && typeof item.title === 'string'
|
||||
).map(item => {
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.issue-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
kind: 'issue-outbox',
|
||||
status: item.status === 'completion' ? 'completion' :
|
||||
(item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'completion' ? 'Created · ready to start' :
|
||||
(item.status === 'attention' ? 'Needs attention' : 'Queued issue')),
|
||||
continuation: item.status === 'completion' && item.completionIntent === 'create-and-start',
|
||||
delivery_state: item.deliveryState,
|
||||
repository: item.repository,
|
||||
title: textPreview(item.title) || 'Untitled queued issue',
|
||||
preview: textPreview([item.title, item.error || item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: [item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function parseAuthoredOutbox(raw) {
|
||||
try {
|
||||
const record = JSON.parse(raw);
|
||||
if (![1, 2].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return record.items.filter(item => item && typeof item.id === 'string' && typeof item.body === 'string')
|
||||
.map(item => {
|
||||
const isUpdate = item.kind === 'update-reply' || item.kind === 'update-reply-read';
|
||||
const isReview = item.kind === 'pull-review';
|
||||
const isClosure = item.kind === 'issue-close';
|
||||
const routeKind = isReview ? 'review' : (item.kind === 'pull-comment' ? 'pull' : 'issue');
|
||||
const target = isUpdate ? 'Update #' + item.notificationId : item.repository + '#' + item.number;
|
||||
const inlineFeedback = isReview && Array.isArray(item.comments) ? item.comments.map(comment =>
|
||||
String(comment.path || 'File') + ' · ' +
|
||||
(comment.new_position ? 'new line ' + comment.new_position : 'old line ' + comment.old_position) +
|
||||
': ' + String(comment.body || '')
|
||||
).filter(Boolean) : [];
|
||||
const copyText = [item.body, inlineFeedback.length ? 'Inline feedback\n' + inlineFeedback.join('\n') : '']
|
||||
.filter(Boolean).join('\n\n');
|
||||
const ownerLogin = String(item.ownerLogin || '').trim();
|
||||
const quarantined = !ownerLogin || !currentLogin || ownerLogin !== currentLogin;
|
||||
return {
|
||||
id: 'stackchain.authored-outbox.v1:' + item.id,
|
||||
outbox_id: item.id,
|
||||
outbox_kind: item.kind,
|
||||
kind: 'authored-outbox',
|
||||
status: item.status === 'attention' ? 'attention' : (item.status === 'sending' ? 'sending' :
|
||||
(item.status === 'authorization' || isClosure ? 'authorization' : 'queued')),
|
||||
label: item.deliveryState === 'uncertain' ? 'Verify delivery' :
|
||||
(item.status === 'attention' ? (isClosure ? 'Issue closure needs attention' : 'Needs attention') :
|
||||
(isReview ? (item.status === 'authorization' ? 'Review awaiting authorization' : 'Queued review') :
|
||||
(isClosure ? 'Awaiting authorization' : 'Queued message'))),
|
||||
authorization_required: isClosure || (isReview && item.status === 'authorization'),
|
||||
delivery_state: item.deliveryState,
|
||||
repository: isUpdate ? '' : item.repository,
|
||||
title: target,
|
||||
preview: textPreview([item.error, item.body].filter(Boolean).join(' — ')),
|
||||
copy_text: copyText,
|
||||
...(isReview ? { head_sha: item.expectedHeadSha } : {}),
|
||||
quarantined,
|
||||
ownership: quarantined ? 'Queued by ' + (ownerLogin || 'an unknown account') +
|
||||
(currentLogin ? ' — current account is ' + currentLogin : ' — account confirmation unavailable') : '',
|
||||
last_attempt_at: Number(item.lastAttemptAt || 0),
|
||||
last_attempt_error: textPreview(item.lastAttemptError),
|
||||
updated_at: Number(item.queuedAt || 0),
|
||||
route: isUpdate ? { kind:'update', notification_id:item.notificationId } :
|
||||
{ kind:routeKind, repository:item.repository, number:item.number },
|
||||
};
|
||||
});
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function list() {
|
||||
const index = readIndex();
|
||||
const seen = new Set();
|
||||
const drafts = [];
|
||||
keys().forEach(key => {
|
||||
if (key === indexKey || key.endsWith(':operation')) return;
|
||||
let raw;
|
||||
try { raw = storage.getItem(key); }
|
||||
catch (_error) { return; }
|
||||
if (key === 'stackchain.issue-outbox.v1') {
|
||||
parseOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
if (key === 'stackchain.authored-outbox.v1') {
|
||||
parseAuthoredOutbox(raw).forEach(item => drafts.push(item));
|
||||
seen.add(key);
|
||||
return;
|
||||
}
|
||||
const parsed = parse(key, raw);
|
||||
if (!parsed) return;
|
||||
seen.add(key);
|
||||
const fingerprint = String(raw);
|
||||
const previous = index[key];
|
||||
if (!previous || previous.fingerprint !== fingerprint) {
|
||||
index[key] = { fingerprint, updated_at: Number(now()) };
|
||||
}
|
||||
drafts.push({ id: key, ...parsed, updated_at: index[key].updated_at });
|
||||
});
|
||||
Object.keys(index).forEach(key => { if (!seen.has(key)) delete index[key]; });
|
||||
writeIndex(index);
|
||||
return drafts.sort((left, right) =>
|
||||
Number(right.updated_at || 0) - Number(left.updated_at || 0) || left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
if (typeof id !== 'string' || !parse(id, (() => {
|
||||
try { return storage?.getItem(id); } catch (_error) { return null; }
|
||||
})())) return false;
|
||||
try {
|
||||
storage?.removeItem(id);
|
||||
storage?.removeItem(id + ':operation');
|
||||
const index = readIndex();
|
||||
delete index[id];
|
||||
writeIndex(index);
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function partition(items = list()) {
|
||||
const deliveries = items.filter(item => item.kind === 'issue-outbox' || item.kind === 'authored-outbox');
|
||||
const drafts = items.filter(item => item.kind !== 'issue-outbox' && item.kind !== 'authored-outbox');
|
||||
const counts = deliveries.reduce((summary, item) => {
|
||||
const state = item.status === 'sending' ? 'sending' : (item.status === 'attention' ? 'attention' :
|
||||
(item.status === 'authorization' ? 'authorization' : 'waiting'));
|
||||
summary[state] += 1;
|
||||
return summary;
|
||||
}, { waiting:0, sending:0, attention:0, authorization:0 });
|
||||
const retryable = deliveries.filter(item =>
|
||||
item.status === 'queued' && !item.authorization_required && !item.quarantined && item.delivery_state !== 'uncertain'
|
||||
);
|
||||
return { drafts, deliveries, counts, retryable };
|
||||
}
|
||||
|
||||
return { list, discard, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox;
|
||||
@@ -0,0 +1,63 @@
|
||||
function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
const pending = new Map();
|
||||
const loaded = new Set();
|
||||
|
||||
function load(name) {
|
||||
if (loaded.has(name)) return Promise.resolve();
|
||||
if (pending.has(name)) return pending.get(name);
|
||||
const url = urls[name];
|
||||
if (!url) return Promise.reject(new Error('Unknown feature: ' + name));
|
||||
|
||||
const request = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
let timeout;
|
||||
const fail = () => {
|
||||
clearTimeout(timeout);
|
||||
script.remove();
|
||||
pending.delete(name);
|
||||
reject(new Error('Could not load ' + name.replace(/-/g, ' ') + '.'));
|
||||
};
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
clearTimeout(timeout);
|
||||
pending.delete(name);
|
||||
loaded.add(name);
|
||||
resolve();
|
||||
};
|
||||
script.onerror = fail;
|
||||
timeout = setTimeout(fail, timeoutMs);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
pending.set(name, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async function run(name, elements, callback) {
|
||||
const trigger = elements?.trigger;
|
||||
const status = elements?.status;
|
||||
const retryLabel = elements?.retryLabel || 'Tap New issue to retry.';
|
||||
if (trigger) trigger.disabled = true;
|
||||
if (status) status.textContent = 'Loading ' + name.replace(/-/g, ' ') + '…';
|
||||
try {
|
||||
await load(name);
|
||||
callback();
|
||||
if (status) status.textContent = '';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' :
|
||||
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (trigger) trigger.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { load, run, ready: name => loaded.has(name) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFeatureLoader;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+958
-425
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createInstallApp = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
|
||||
const dismissedKey = 'stackchain.install.dismissed.v1';
|
||||
let deferredPrompt = options.initialPrompt || null;
|
||||
let installed = false;
|
||||
|
||||
function hide() {
|
||||
options.card.hidden = true;
|
||||
}
|
||||
|
||||
function showNativePrompt(event) {
|
||||
event.preventDefault();
|
||||
deferredPrompt = event;
|
||||
options.guidance.hidden = true;
|
||||
options.installButton.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!deferredPrompt) return;
|
||||
const prompt = deferredPrompt;
|
||||
deferredPrompt = null;
|
||||
options.installButton.disabled = true;
|
||||
await prompt.prompt();
|
||||
const choice = await prompt.userChoice;
|
||||
if (choice.outcome === 'accepted') {
|
||||
installed = true;
|
||||
hide();
|
||||
options.status.textContent = 'Stackchain was added to your device.';
|
||||
} else {
|
||||
hide();
|
||||
options.installButton.disabled = false;
|
||||
options.status.textContent = 'Installation was not completed.';
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
options.storage.setItem(dismissedKey, '1');
|
||||
hide();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (options.isStandalone() || options.storage.getItem(dismissedKey) === '1') {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
if (deferredPrompt) showNativePrompt(deferredPrompt);
|
||||
if (options.isIosSafari()) {
|
||||
options.installButton.hidden = true;
|
||||
options.guidance.hidden = false;
|
||||
options.card.hidden = false;
|
||||
}
|
||||
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
|
||||
options.window.addEventListener('appinstalled', () => {
|
||||
installed = true;
|
||||
deferredPrompt = null;
|
||||
hide();
|
||||
});
|
||||
options.installButton.addEventListener('click', install);
|
||||
options.dismissButton.addEventListener('click', dismiss);
|
||||
}
|
||||
|
||||
function state() {
|
||||
if (installed || options.isStandalone()) {
|
||||
return {state:'complete', detail:'Stackchain is installed on this device.'};
|
||||
}
|
||||
if (options.isIosSafari()) {
|
||||
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
|
||||
}
|
||||
if (deferredPrompt) {
|
||||
return {state:'incomplete', detail:'Stackchain is ready to install.'};
|
||||
}
|
||||
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
|
||||
}
|
||||
|
||||
return {start, install, state};
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.issueAttachment = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function namedBlob(blob, name) {
|
||||
if (typeof File === 'function') {
|
||||
return new File([blob], name, { type: blob.type });
|
||||
}
|
||||
Object.defineProperty(blob, 'name', { value: name, configurable: true });
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function optimizeImage(file, environment = {}) {
|
||||
if (file.size <= MAX_BYTES) return file;
|
||||
const decode = environment.createImageBitmap || globalThis.createImageBitmap;
|
||||
const makeCanvas = environment.createCanvas || (() => document.createElement('canvas'));
|
||||
if (typeof decode !== 'function') {
|
||||
throw new Error('This browser cannot optimize the screenshot. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await decode(file);
|
||||
const canvas = makeCanvas();
|
||||
const context = canvas && canvas.getContext && canvas.getContext('2d');
|
||||
if (!context || !bitmap.width || !bitmap.height) throw new Error('decode');
|
||||
let scale = Math.min(1, Math.sqrt(MAX_BYTES / file.size) * 0.92);
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(resolve => {
|
||||
canvas.toBlob(resolve, file.type, file.type === 'image/png' ? undefined : 0.86);
|
||||
});
|
||||
if (!blob) throw new Error('encode');
|
||||
if (blob.size > 0 && blob.size <= MAX_BYTES) return namedBlob(blob, file.name);
|
||||
scale *= 0.8;
|
||||
}
|
||||
} catch (_error) {
|
||||
throw new Error('The screenshot could not be optimized. Try cropping it and choose it again.');
|
||||
} finally {
|
||||
if (bitmap && typeof bitmap.close === 'function') bitmap.close();
|
||||
}
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
|
||||
function multipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const upload = options.upload;
|
||||
const optimizeSelectedImage = options.optimizeImage || optimizeImage;
|
||||
const createOperationId = options.createOperationId || (() => {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
});
|
||||
let selected = null;
|
||||
let confirmed = null;
|
||||
let serialized = null;
|
||||
let operationId = null;
|
||||
let selectionGeneration = 0;
|
||||
|
||||
function commitSelection(file) {
|
||||
if (!file || !IMAGE_TYPES.has(file.type) || !Number.isFinite(file.size) ||
|
||||
file.size <= 0 || file.size > MAX_BYTES) {
|
||||
throw new Error('The screenshot could not be optimized below 2 MB. Try cropping it and choose it again.');
|
||||
}
|
||||
selected = file;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = createOperationId();
|
||||
return state();
|
||||
}
|
||||
|
||||
function select(file) {
|
||||
const generation = ++selectionGeneration;
|
||||
if (!file || !IMAGE_TYPES.has(file.type)) {
|
||||
throw new Error('Choose a PNG, JPEG, or WebP screenshot.');
|
||||
}
|
||||
if (!Number.isFinite(file.size) || file.size <= 0) {
|
||||
throw new Error('Choose a screenshot that is 2 MB or smaller.');
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return Promise.resolve(optimizeSelectedImage(file)).then(optimized => {
|
||||
if (generation !== selectionGeneration) return state();
|
||||
return commitSelection(optimized);
|
||||
});
|
||||
}
|
||||
return commitSelection(file);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectionGeneration += 1;
|
||||
selected = null;
|
||||
confirmed = null;
|
||||
serialized = null;
|
||||
operationId = null;
|
||||
}
|
||||
|
||||
function restore(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '');
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!blob && !data) {
|
||||
clear();
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const padding = (data.match(/=*$/) || [''])[0].length;
|
||||
const size = blob ? Number(blob.size) : Math.max(1, Math.floor(data.length * 3 / 4) - padding);
|
||||
select({ name: filename, type: contentType, size, ...(blob ? { blob } : {}) });
|
||||
serialized = blob ? { filename, contentType, blob } : { filename, contentType, data };
|
||||
return state();
|
||||
}
|
||||
|
||||
function state() {
|
||||
return selected ? {
|
||||
name: selected.name,
|
||||
size: selected.size,
|
||||
uploaded: Boolean(confirmed),
|
||||
} : null;
|
||||
}
|
||||
|
||||
async function serialize() {
|
||||
if (!selected) return null;
|
||||
if (!serialized) {
|
||||
serialized = {
|
||||
filename: selected.name,
|
||||
contentType: selected.type,
|
||||
blob: selected.blob || selected,
|
||||
};
|
||||
}
|
||||
return { ...serialized };
|
||||
}
|
||||
|
||||
async function prepareComment(item, body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!selected) return text;
|
||||
if (!confirmed) {
|
||||
const attachment = await serialize();
|
||||
confirmed = await upload({
|
||||
repository: item.repository,
|
||||
number: item.number,
|
||||
filename: attachment.filename,
|
||||
content_type: attachment.contentType,
|
||||
...(attachment.blob ? { blob: attachment.blob } : { data: attachment.data }),
|
||||
operation_id: operationId,
|
||||
});
|
||||
if (!confirmed || typeof confirmed.markdown !== 'string' || !confirmed.markdown) {
|
||||
confirmed = null;
|
||||
throw new Error('The server did not confirm the screenshot upload.');
|
||||
}
|
||||
}
|
||||
return text ? text + '\n\n' + confirmed.markdown : confirmed.markdown;
|
||||
}
|
||||
|
||||
return { select, restore, clear, state, serialize, prepareComment };
|
||||
}
|
||||
|
||||
function mount(options) {
|
||||
const controller = create(options);
|
||||
const clearSelection = controller.clear;
|
||||
let previewUrl = '';
|
||||
let selectionSequence = 0;
|
||||
|
||||
function setBusy(busy) {
|
||||
options.input.disabled = Boolean(busy);
|
||||
options.remove.disabled = Boolean(busy);
|
||||
}
|
||||
|
||||
function clearPreview() {
|
||||
selectionSequence += 1;
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = '';
|
||||
options.image.src = '';
|
||||
options.preview.hidden = true;
|
||||
options.input.value = '';
|
||||
options.input.disabled = false;
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
function showPreview(file, optimized) {
|
||||
if (previewUrl) options.revokeObjectURL(previewUrl);
|
||||
previewUrl = options.createObjectURL(file);
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = file.name + ' · ' + Math.ceil(file.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = optimized ? 'Screenshot optimized and ready to upload.' :
|
||||
(options.readyMessage || 'Screenshot ready to upload with this comment.');
|
||||
}
|
||||
|
||||
options.input.addEventListener('change', event => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
const sequence = ++selectionSequence;
|
||||
let result;
|
||||
try {
|
||||
result = controller.select(file);
|
||||
} catch (error) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!result || typeof result.then !== 'function') {
|
||||
showPreview(file, false);
|
||||
return;
|
||||
}
|
||||
options.status.textContent = 'Optimizing screenshot…';
|
||||
options.input.disabled = true;
|
||||
return result.then(() => controller.serialize()).then(value => {
|
||||
if (sequence === selectionSequence) showPreview(value.blob, true);
|
||||
}).catch(error => {
|
||||
if (sequence === selectionSequence) {
|
||||
options.status.textContent = error.message;
|
||||
options.input.value = '';
|
||||
}
|
||||
}).finally(() => {
|
||||
if (sequence === selectionSequence) options.input.disabled = false;
|
||||
});
|
||||
});
|
||||
options.remove.addEventListener('click', () => {
|
||||
clearPreview();
|
||||
options.status.textContent = options.removedMessage || 'Screenshot removed. Your comment is unchanged.';
|
||||
});
|
||||
|
||||
function restorePreview(value) {
|
||||
clearPreview();
|
||||
const restored = controller.restore(value);
|
||||
previewUrl = value.blob ? options.createObjectURL(value.blob) :
|
||||
'data:' + value.contentType + ';base64,' + value.data;
|
||||
options.image.src = previewUrl;
|
||||
options.meta.textContent = restored.name + ' · ' + Math.ceil(restored.size / 1024) + ' KB';
|
||||
options.preview.hidden = false;
|
||||
options.status.textContent = options.readyMessage || 'Screenshot ready to upload with this comment.';
|
||||
return restored;
|
||||
}
|
||||
|
||||
return Object.assign(controller, { clear: clearPreview, restore: restorePreview, setBusy });
|
||||
}
|
||||
|
||||
return { create, mount, multipart, optimizeImage, MAX_BYTES };
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, getOwnerLogin = () => '', createOperationId, now = () => Date.now(), maxItems = 20 }) {
|
||||
const storageKey = 'stackchain.issue-outbox.v1';
|
||||
const operationId = createOperationId || (() =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2)
|
||||
);
|
||||
const pending = new Map();
|
||||
|
||||
function captureAttachment(value) {
|
||||
const contentType = String(value?.contentType || '');
|
||||
const filename = String(value?.filename || '').slice(0, 255);
|
||||
const blob = value?.blob;
|
||||
const data = String(value?.data || '');
|
||||
if (!filename || !['image/png', 'image/jpeg', 'image/webp'].includes(contentType)) return undefined;
|
||||
if (blob) return { filename, contentType, blob };
|
||||
if (!data && value?.stored === true) return { filename, contentType, stored: true };
|
||||
if (!data) return undefined;
|
||||
return { filename, contentType, data };
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (![1, 2, 3].includes(record?.version) || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item && typeof item === 'object');
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 3, items }));
|
||||
coordinator?.notify('issue');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains the compatibility fallback. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function prepareItem(draft) {
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing an issue.');
|
||||
const items = read();
|
||||
if (items.length >= maxItems) throw new Error('Issue outbox is full. Send or discard a queued issue first.');
|
||||
const item = {
|
||||
id: String(operationId()).slice(0, 128),
|
||||
operationId: '',
|
||||
repository: String(draft?.repository || ''),
|
||||
title: String(draft?.title || ''),
|
||||
body: String(draft?.body || ''),
|
||||
labelIds: Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [],
|
||||
ownerLogin,
|
||||
status: 'queued',
|
||||
queuedAt: Number(now()),
|
||||
};
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
|
||||
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
|
||||
const attachment = captureAttachment(draft?.attachment);
|
||||
if (attachment) item.attachment = attachment;
|
||||
item.operationId = item.id;
|
||||
if (Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0) {
|
||||
item.milestoneId = Number(draft.milestoneId);
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))) item.dueDate = String(draft.dueDate);
|
||||
return item;
|
||||
}
|
||||
|
||||
function localIndexItem(item) {
|
||||
if (!item?.attachment?.data && !item?.attachment?.blob) return item;
|
||||
return {
|
||||
...item,
|
||||
attachment: {
|
||||
filename: item.attachment.filename,
|
||||
contentType: item.attachment.contentType,
|
||||
stored: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function enqueue(draft, mirror = true) {
|
||||
const items = read();
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) return { ...existing };
|
||||
const item = prepareItem(draft);
|
||||
items.push(item);
|
||||
write(items, mirror);
|
||||
return item;
|
||||
}
|
||||
|
||||
async function enqueueDurably(draft) {
|
||||
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
|
||||
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
|
||||
if (existing) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
|
||||
}
|
||||
await backgroundSync.reconcile(read());
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...existing }, background: true, durability: 'background', reused: true };
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = enqueue(draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const item = prepareItem(draft);
|
||||
const current = read();
|
||||
try {
|
||||
await backgroundSync.reconcile([...current, item]);
|
||||
const localItem = localIndexItem(item);
|
||||
write([...current, item].map(localIndexItem), false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item: localItem, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
const persisted = read().find(candidate => candidate.id === item.id);
|
||||
if (!persisted) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateForEdit(id) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return null;
|
||||
if (!item.attachment?.stored || item.attachment.data || item.attachment.blob) return { ...item };
|
||||
if (!backgroundSync?.get) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
const durable = await backgroundSync.get(id);
|
||||
const attachment = captureAttachment(durable?.attachment);
|
||||
if ((!attachment?.data && !attachment?.blob) || durable?.operationId !== item.operationId) {
|
||||
throw new Error('The saved screenshot is unavailable. Retry before editing this issue.');
|
||||
}
|
||||
return { ...item, attachment };
|
||||
}
|
||||
|
||||
function prepareUpdate(id, draft) {
|
||||
let updated = null;
|
||||
const items = read().map(item => {
|
||||
if (item.id !== id) return item;
|
||||
const nextRepository = String(draft?.repository || '');
|
||||
const nextTitle = String(draft?.title || '');
|
||||
const nextBody = String(draft?.body || '');
|
||||
const nextLabelIds = Array.isArray(draft?.labelIds) ? draft.labelIds.filter(Number.isInteger).slice(0, 20) : [];
|
||||
const nextMilestoneId = Number.isInteger(Number(draft?.milestoneId)) && Number(draft.milestoneId) > 0
|
||||
? Number(draft.milestoneId) : undefined;
|
||||
const nextDueDate = /^\d{4}-\d{2}-\d{2}$/.test(String(draft?.dueDate || ''))
|
||||
? String(draft.dueDate) : undefined;
|
||||
const nextAttachment = captureAttachment(draft?.attachment);
|
||||
const attachmentChanged = JSON.stringify(item.attachment || null) !== JSON.stringify(nextAttachment || null);
|
||||
const changed = item.repository !== nextRepository || item.title !== nextTitle || item.body !== nextBody
|
||||
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|
||||
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|
||||
|| attachmentChanged;
|
||||
updated = {
|
||||
...item,
|
||||
repository: nextRepository, title: nextTitle,
|
||||
body: nextBody, labelIds: nextLabelIds,
|
||||
milestoneId: nextMilestoneId, dueDate: nextDueDate, attachment: nextAttachment,
|
||||
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
|
||||
status: 'queued',
|
||||
};
|
||||
if (draft?.completionIntent === 'create-and-start') updated.completionIntent = 'create-and-start';
|
||||
else delete updated.completionIntent;
|
||||
if (nextMilestoneId === undefined) delete updated.milestoneId;
|
||||
if (nextDueDate === undefined) delete updated.dueDate;
|
||||
if (nextAttachment === undefined) delete updated.attachment;
|
||||
if (attachmentChanged) delete updated.attachmentMarkdown;
|
||||
delete updated.error;
|
||||
delete updated.deliveryState;
|
||||
return updated;
|
||||
});
|
||||
return { items, updated };
|
||||
}
|
||||
|
||||
function update(id, draft, mirror = true) {
|
||||
const { items, updated } = prepareUpdate(id, draft);
|
||||
write(items, mirror);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateDurably(id, draft) {
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
const item = update(id, draft, false);
|
||||
return { item, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
const { items, updated: item } = prepareUpdate(id, draft);
|
||||
if (!item) return { item, background: false, durability: 'foreground-only' };
|
||||
try {
|
||||
await backgroundSync.reconcile(items);
|
||||
const localItems = items.map(localIndexItem);
|
||||
write(localItems, false);
|
||||
await backgroundSync.requestSync();
|
||||
return { item: localIndexItem(item), background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
const persisted = read().find(candidate => candidate.id === id);
|
||||
if (!persisted || persisted.operationId !== item.operationId) throw error;
|
||||
return { item: persisted, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
if (!items.some(item => item.id === id)) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistDeliveryStage(id, stage) {
|
||||
write(read().map(item => item.id === id ? { ...item, ...stage } : item));
|
||||
}
|
||||
|
||||
function stageOperationId(operationId, stage) {
|
||||
const suffix = ':' + stage;
|
||||
return String(operationId || '').slice(0, 128 - suffix.length) + suffix;
|
||||
}
|
||||
|
||||
function attachmentMultipart(attachment) {
|
||||
let blob = attachment?.blob;
|
||||
if (!blob && attachment?.data) {
|
||||
const binary = atob(String(attachment.data));
|
||||
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
blob = new Blob([bytes], { type: String(attachment.contentType || '') });
|
||||
}
|
||||
if (!blob) throw new Error('The saved screenshot is unavailable. Retry before sending.');
|
||||
const form = new FormData();
|
||||
form.append('file', blob, String(attachment.filename || 'screenshot'));
|
||||
return form;
|
||||
}
|
||||
|
||||
async function sendDirect(item, repository) {
|
||||
let issue = item.deliveredIssue;
|
||||
if (!issue) {
|
||||
issue = await fetchJson('api/v1/repos/' + repository + '/issues', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': item.operationId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: item.title, body: item.body, label_ids: item.labelIds,
|
||||
...(item.milestoneId ? { milestone_id: item.milestoneId } : {}),
|
||||
...(item.dueDate ? { due_date: item.dueDate + 'T23:59:59Z' } : {}),
|
||||
}),
|
||||
});
|
||||
if (item.attachment) persistDeliveryStage(item.id, { deliveredIssue: issue });
|
||||
}
|
||||
if (!item.attachment) return issue;
|
||||
let markdown = item.attachmentMarkdown;
|
||||
if (!markdown) {
|
||||
const uploaded = await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/attachments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment'),
|
||||
},
|
||||
body: attachmentMultipart(item.attachment),
|
||||
},
|
||||
);
|
||||
markdown = String(uploaded?.markdown || '');
|
||||
if (!markdown) {
|
||||
const error = new Error('The server did not confirm the screenshot upload.');
|
||||
error.status = 422;
|
||||
throw error;
|
||||
}
|
||||
persistDeliveryStage(item.id, { deliveredIssue: issue, attachmentMarkdown: markdown });
|
||||
}
|
||||
await fetchJson(
|
||||
'api/v1/repos/' + repository + '/issues/' + encodeURIComponent(issue.number) + '/comments',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': stageOperationId(item.operationId, 'attachment-comment'),
|
||||
},
|
||||
body: JSON.stringify({ body: markdown }),
|
||||
},
|
||||
);
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function sendItem(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (pending.has(item.id)) return pending.get(item.id);
|
||||
const attemptAt = Number(now());
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'sending', lastAttemptAt:attemptAt,
|
||||
} : candidate), false);
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
const request = (async () => {
|
||||
try {
|
||||
let issue;
|
||||
if (backgroundSync?.send) {
|
||||
const delivery = await backgroundSync.send(item, currentLogin);
|
||||
if (delivery.attention) {
|
||||
const error = delivery.error || new Error('Issue needs attention');
|
||||
error.status = Number(error.status || 422);
|
||||
throw error;
|
||||
}
|
||||
issue = delivery.issue;
|
||||
} else {
|
||||
issue = await sendDirect(item, repository);
|
||||
}
|
||||
if (!issue) return { blocked: true };
|
||||
discard(item.id);
|
||||
return { issue, item };
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
const attemptError = String(error.message || 'Delivery failed').slice(0, 240);
|
||||
if (status >= 400 && status < 500) {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error.message || 'Issue needs attention').slice(0, 240),
|
||||
lastAttemptAt: attemptAt, lastAttemptError: attemptError,
|
||||
...(error.code === 'delivery_uncertain' ? { deliveryState: 'uncertain' } : {}),
|
||||
} : candidate));
|
||||
} else {
|
||||
write(read().map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status:'queued', lastAttemptAt:attemptAt, lastAttemptError:attemptError,
|
||||
} : candidate));
|
||||
}
|
||||
return { error, transient: !(status >= 400 && status < 500) };
|
||||
}
|
||||
})();
|
||||
pending.set(item.id, request);
|
||||
try { return await request; }
|
||||
finally { if (pending.get(item.id) === request) pending.delete(item.id); }
|
||||
}
|
||||
|
||||
async function flushQueue(currentLogin) {
|
||||
const confirmed = [];
|
||||
const completions = [];
|
||||
let blocked = 0;
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
for (const item of read()) {
|
||||
if (item.status === 'attention' || item.status === 'completion') continue;
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) { blocked += 1; continue; }
|
||||
const result = await sendItem(item, currentLogin);
|
||||
if (result.issue) {
|
||||
confirmed.push(result.issue);
|
||||
if (result.item?.completionIntent) completions.push({
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
});
|
||||
}
|
||||
if (result.transient) break;
|
||||
}
|
||||
return { confirmed, completions, remaining: read(), blocked };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => flushQueue(currentLogin));
|
||||
return flushQueue(currentLogin);
|
||||
}
|
||||
|
||||
async function retryItem(id, currentLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) return { confirmed: [], remaining: read() };
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) {
|
||||
return { confirmed: [], remaining: read(), blocked: 1 };
|
||||
}
|
||||
let queued = item;
|
||||
if (item.status === 'attention') {
|
||||
queued = {
|
||||
...item,
|
||||
operationId: String(operationId()).slice(0, 128),
|
||||
status: 'queued',
|
||||
};
|
||||
delete queued.error;
|
||||
delete queued.deliveryState;
|
||||
write(read().map(candidate => candidate.id === id ? queued : candidate));
|
||||
}
|
||||
const result = await sendItem(queued, currentLogin);
|
||||
return {
|
||||
confirmed: result.issue ? [result.issue] : [],
|
||||
completions: result.issue && result.item?.completionIntent ? [{
|
||||
id: result.item.id,
|
||||
intent: result.item.completionIntent,
|
||||
ownerLogin: result.item.ownerLogin,
|
||||
operationId: result.item.operationId,
|
||||
issue: result.issue,
|
||||
}] : [],
|
||||
remaining: read(), blocked: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function retry(id, currentLogin) {
|
||||
if (coordinator) return coordinator.runExclusive('issue', () => retryItem(id, currentLogin));
|
||||
return retryItem(id, currentLogin);
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const statuses = new Map((records || []).map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const background = statuses.get(item.id);
|
||||
const deliveryStage = {
|
||||
...(background?.deliveredIssue ? { deliveredIssue: background.deliveredIssue } : {}),
|
||||
...(background?.attachmentMarkdown ? { attachmentMarkdown: background.attachmentMarkdown } : {}),
|
||||
};
|
||||
if (background?.status === 'sent') {
|
||||
if (item.completionIntent === 'create-and-start' && background.deliveredIssue) return [{
|
||||
...item, status: 'completion', deliveredIssue: background.deliveredIssue,
|
||||
}];
|
||||
return [];
|
||||
}
|
||||
if (background?.status === 'attention') return [{
|
||||
...localIndexItem(item), ...deliveryStage, status: 'attention', error: String(background.error || 'Issue needs attention').slice(0, 240),
|
||||
...(background.deliveryState ? { deliveryState: background.deliveryState } : {}),
|
||||
}];
|
||||
return [{ ...localIndexItem(item), ...deliveryStage }];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
function pendingCompletions(currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
if (!login) return [];
|
||||
return read().filter(item => item.status === 'completion' && item.ownerLogin === login &&
|
||||
item.completionIntent === 'create-and-start' && item.deliveredIssue).map(item => ({
|
||||
id: item.id,
|
||||
intent: item.completionIntent,
|
||||
ownerLogin: item.ownerLogin,
|
||||
operationId: item.operationId,
|
||||
issue: item.deliveredIssue,
|
||||
}));
|
||||
}
|
||||
|
||||
function completeIntent(id, currentLogin) {
|
||||
const login = String(currentLogin || '').trim();
|
||||
const items = read();
|
||||
const matched = items.some(item => item.id === id && item.status === 'completion' && item.ownerLogin === login);
|
||||
if (!matched) return false;
|
||||
write(items.filter(item => item.id !== id));
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue, enqueueDurably, hydrateForEdit, update, updateDurably, discard, flush, retry, reconcileBackground,
|
||||
pendingCompletions, completeIntent, list: () => read().map(item => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueOutbox;
|
||||
@@ -0,0 +1,283 @@
|
||||
function createPlanningLoader({ loadLabels, loadMilestones }) {
|
||||
const requests = new Map();
|
||||
const keyFor = item => item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
const key = keyFor(item);
|
||||
if (requests.has(key)) return requests.get(key);
|
||||
const request = Promise.all([loadLabels(item), loadMilestones(item)])
|
||||
.then(([labels, milestones]) => ({ labels, milestones }))
|
||||
.catch(error => {
|
||||
if (requests.get(key) === request) requests.delete(key);
|
||||
throw error;
|
||||
});
|
||||
requests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let closeRequest = null;
|
||||
let releaseRequest = null;
|
||||
let handoffRequest = null;
|
||||
let labelRequest = null;
|
||||
let editRequest = null;
|
||||
let dueDateRequest = null;
|
||||
let milestoneRequest = null;
|
||||
let blockerRequest = null;
|
||||
const issuePath = item => 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.issue-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const labelDraftKey = item => 'stackchain.issue-labels.v1:' + item.repository + '#' + item.number;
|
||||
const editDraftKey = item => 'stackchain.issue-content.v1:' + item.repository + '#' + item.number;
|
||||
const dueDateDraftKey = item => 'stackchain.issue-due-date.v1:' + item.repository + '#' + item.number;
|
||||
const milestoneDraftKey = item => 'stackchain.issue-milestone.v1:' + item.repository + '#' + item.number;
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(issuePath(item) + '/detail', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(issuePath(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
},
|
||||
loadLabels(item) {
|
||||
return fetchJson(issuePath(item) + '/labels', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadMilestones(item) {
|
||||
const repository = item.repository.split('/').map(encodeURIComponent).join('/');
|
||||
return fetchJson('api/v1/repos/' + repository + '/milestones', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
return fetchJson(issuePath(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
},
|
||||
updateBlocker(item, blocker, remove = false) {
|
||||
if (blockerRequest) return blockerRequest;
|
||||
blockerRequest = fetchJson(issuePath(item) + '/blockers', {
|
||||
method: remove ? 'DELETE' : 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ repository: blocker.repository, number: blocker.number }),
|
||||
}).then(result => {
|
||||
const dependencies = Array.isArray(result?.dependencies) ? result.dependencies : [];
|
||||
const present = dependencies.some(candidate =>
|
||||
candidate?.repository === blocker.repository && Number(candidate?.number) === Number(blocker.number)
|
||||
);
|
||||
if (result?.number !== item.number || result?.dependencies_available !== true || present === remove) {
|
||||
throw new Error('Blocker change was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { blockerRequest = null; });
|
||||
return blockerRequest;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
loadEditDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(editDraftKey(item)) || 'null');
|
||||
return value && typeof value.title === 'string' && typeof value.body === 'string' &&
|
||||
typeof value.expectedUpdatedAt === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
saveEditDraft(item, draft) {
|
||||
try { storage?.setItem(editDraftKey(item), JSON.stringify(draft)); }
|
||||
catch (_error) { /* The edit fields remain the fallback. */ }
|
||||
},
|
||||
updateContent(item, draft) {
|
||||
if (editRequest) return editRequest;
|
||||
this.saveEditDraft(item, draft);
|
||||
editRequest = fetchJson(issuePath(item) + '/content', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
expected_updated_at: draft.expectedUpdatedAt,
|
||||
}),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.title !== draft.title || result?.body !== draft.body) {
|
||||
throw new Error('Issue update was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(editDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream content is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { editRequest = null; });
|
||||
return editRequest;
|
||||
},
|
||||
loadDueDateDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(dueDateDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return typeof value === 'string' ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateDueDate(item, dueDate) {
|
||||
if (dueDateRequest) return dueDateRequest;
|
||||
try { storage?.setItem(dueDateDraftKey(item), JSON.stringify(dueDate)); }
|
||||
catch (_error) { /* The date input remains the fallback. */ }
|
||||
dueDateRequest = fetchJson(issuePath(item) + '/due-date', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ due_date: dueDate }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || result?.due_date !== dueDate) {
|
||||
throw new Error('Issue due date was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(dueDateDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream deadline is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { dueDateRequest = null; });
|
||||
return dueDateRequest;
|
||||
},
|
||||
loadMilestoneDraft(item) {
|
||||
try {
|
||||
const raw = storage?.getItem(milestoneDraftKey(item));
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const value = JSON.parse(raw);
|
||||
return value === null || Number.isInteger(value) ? value : null;
|
||||
} catch (_error) { return null; }
|
||||
},
|
||||
updateMilestone(item, milestoneId) {
|
||||
if (milestoneRequest) return milestoneRequest;
|
||||
try { storage?.setItem(milestoneDraftKey(item), JSON.stringify(milestoneId)); }
|
||||
catch (_error) { /* The select remains the fallback. */ }
|
||||
milestoneRequest = fetchJson(issuePath(item) + '/milestone', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ milestone_id: milestoneId }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number ||
|
||||
(milestoneId === null ? result?.milestone !== null : result?.milestone?.id !== milestoneId)) {
|
||||
throw new Error('Issue milestone was not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(milestoneDraftKey(item)); }
|
||||
catch (_error) { /* Confirmed upstream milestone is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { milestoneRequest = null; });
|
||||
return milestoneRequest;
|
||||
},
|
||||
loadLabelDraft(item) {
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(labelDraftKey(item)) || '[]');
|
||||
return Array.isArray(value) ? value.filter(Number.isInteger) : [];
|
||||
} catch (_error) { return []; }
|
||||
},
|
||||
updateLabels(item, labelIds) {
|
||||
if (labelRequest) return labelRequest;
|
||||
try { storage?.setItem(labelDraftKey(item), JSON.stringify(labelIds)); }
|
||||
catch (_error) { /* The checked controls remain the fallback. */ }
|
||||
labelRequest = fetchJson(issuePath(item) + '/labels', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label_ids: labelIds }),
|
||||
}).then(result => {
|
||||
if (result?.number !== item.number || !Array.isArray(result.labels)) {
|
||||
throw new Error('Issue labels were not confirmed.');
|
||||
}
|
||||
try { storage?.removeItem(labelDraftKey(item)); }
|
||||
catch (_error) { /* The upstream label set is authoritative. */ }
|
||||
return result;
|
||||
}).finally(() => { labelRequest = null; });
|
||||
return labelRequest;
|
||||
},
|
||||
close(item) {
|
||||
if (closeRequest) return closeRequest;
|
||||
closeRequest = fetchJson(issuePath(item) + '/close', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (result?.state !== 'closed') throw new Error('Issue closure was not confirmed.');
|
||||
return result;
|
||||
}).finally(() => { closeRequest = null; });
|
||||
return closeRequest;
|
||||
},
|
||||
release(item, currentLogin) {
|
||||
if (releaseRequest) return releaseRequest;
|
||||
releaseRequest = fetchJson(issuePath(item) + '/release', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue release was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { releaseRequest = null; });
|
||||
return releaseRequest;
|
||||
},
|
||||
handoff(item, recipient, currentLogin) {
|
||||
if (handoffRequest) return handoffRequest;
|
||||
handoffRequest = fetchJson(issuePath(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).then(result => {
|
||||
if (
|
||||
result?.number !== item.number ||
|
||||
result?.recipient !== recipient ||
|
||||
!Array.isArray(result.assignees) ||
|
||||
!result.assignees.includes(recipient) ||
|
||||
result.assignees.includes(currentLogin)
|
||||
) {
|
||||
throw new Error('Issue handoff was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { handoffRequest = null; });
|
||||
return handoffRequest;
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(issuePath(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createIssueSheet.createPlanningLoader = createPlanningLoader;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createIssueSheet;
|
||||
@@ -0,0 +1,51 @@
|
||||
function createLaterAndStart({ todayWork, todaySync, laterWork, refresh, warm, start, announce }) {
|
||||
const pending = new Map();
|
||||
|
||||
async function run(item, identity) {
|
||||
const added = todayWork.add(item);
|
||||
if (added === 'full') {
|
||||
announce('Today is limited to 5 items. Remove one, then try Start now again.');
|
||||
return 'full';
|
||||
}
|
||||
if (added !== 'added' && added !== 'exists') {
|
||||
announce('Could not save Today on this device. The item remains in Later; try again.');
|
||||
return added;
|
||||
}
|
||||
if (added === 'added' && !todaySync.enqueue('add', identity)) {
|
||||
todayWork.remove(item);
|
||||
announce('Today sync is unavailable. The item remains in Later; try again.');
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
if (!laterWork.restore(item)) {
|
||||
if (added === 'added') {
|
||||
todayWork.remove(item);
|
||||
todaySync.enqueue('remove', identity);
|
||||
todaySync.flush();
|
||||
}
|
||||
announce('Could not remove this item from Later. Nothing was started; try again.');
|
||||
return 'later-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
const outcome = await start(item);
|
||||
if (outcome === 'gated') {
|
||||
announce('Moved to Today. Choose how to handle its blocker before starting.');
|
||||
return 'gated';
|
||||
}
|
||||
announce(added === 'exists' ? 'Opened the existing Today item.' : 'Moved to Today and opened.');
|
||||
return 'started';
|
||||
}
|
||||
|
||||
function startDeferred(item) {
|
||||
const identity = todayWork.identity(item);
|
||||
if (pending.has(identity)) return pending.get(identity);
|
||||
const operation = run(item, identity).finally(() => pending.delete(identity));
|
||||
pending.set(identity, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { start: startDeferred };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterAndStart;
|
||||
@@ -0,0 +1,104 @@
|
||||
function createLaterPicker({
|
||||
now = () => new Date(),
|
||||
history = null,
|
||||
eventTarget = null,
|
||||
onState = () => {},
|
||||
onConfirm = () => false,
|
||||
} = {}) {
|
||||
let active = false;
|
||||
let item = null;
|
||||
let trigger = null;
|
||||
let context = null;
|
||||
let afterClose = null;
|
||||
let committed = false;
|
||||
let started = false;
|
||||
|
||||
function parse(input) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
|
||||
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
const parts = match.slice(1).map(Number);
|
||||
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
|
||||
const sameLocalTime = value.getFullYear() === parts[0] &&
|
||||
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
|
||||
value.getHours() === parts[3] && value.getMinutes() === parts[4];
|
||||
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
|
||||
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
|
||||
return { ok:true, value };
|
||||
}
|
||||
|
||||
function formatLocal(value) {
|
||||
const pad = part => String(part).padStart(2, '0');
|
||||
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
|
||||
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (!active) return false;
|
||||
const restore = trigger;
|
||||
active = false;
|
||||
item = null;
|
||||
trigger = null;
|
||||
context = null;
|
||||
committed = false;
|
||||
onState({ open:false, message:'' });
|
||||
restore?.focus?.();
|
||||
const cleanup = afterClose;
|
||||
afterClose = null;
|
||||
cleanup?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started || !eventTarget) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => {
|
||||
if (active && !event.state?.laterPicker) deactivate();
|
||||
});
|
||||
eventTarget.addEventListener('keydown', event => {
|
||||
if (active && event.key === 'Escape') {
|
||||
event.preventDefault?.();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open(nextItem, nextTrigger, nextContext = 'card') {
|
||||
if (!nextItem || active) return false;
|
||||
active = true;
|
||||
item = nextItem;
|
||||
trigger = nextTrigger || null;
|
||||
context = nextContext;
|
||||
committed = false;
|
||||
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
|
||||
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
|
||||
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
|
||||
onState({ open:true, message:'', value:formatLocal(suggested) });
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!active) return false;
|
||||
if (history?.state?.laterPicker) history.back();
|
||||
else deactivate();
|
||||
return true;
|
||||
}
|
||||
|
||||
function submit(input) {
|
||||
if (!active || committed) return false;
|
||||
const result = parse(input);
|
||||
if (!result.ok) {
|
||||
onState({ open:true, message:result.message, value:String(input || '') });
|
||||
return false;
|
||||
}
|
||||
const confirmed = onConfirm(item, result.value, context);
|
||||
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||
committed = true;
|
||||
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
return { parse, start, open, close, submit, current:() => active };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
|
||||
@@ -0,0 +1,273 @@
|
||||
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.later-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function validRecords(records) {
|
||||
return records && typeof records === 'object' && !Array.isArray(records);
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = { revision: plan.revision, records: plan.records };
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// Server truth remains usable in this tab when storage is unavailable.
|
||||
}
|
||||
onRemoteRecords?.(plan.records);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
const activeRecords = records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id));
|
||||
const latestByItem = new Map();
|
||||
activeRecords.forEach(record => latestByItem.set(record.operation.item_id, record));
|
||||
activeRecords.filter(record => latestByItem.get(record.operation.item_id) !== record).forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
return activeRecords.filter(record => latestByItem.get(record.operation.item_id) === record)
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : 0,
|
||||
})).filter(operation =>
|
||||
operation && typeof operation.operation_id === 'string' &&
|
||||
['defer', 'restore'].includes(operation.action) && typeof operation.item_id === 'string' &&
|
||||
(operation.action === 'restore' || typeof operation.wake_at === 'string'));
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, wakeAt = null) {
|
||||
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||
pending().filter(operation => operation.item_id === itemId)
|
||||
.forEach(operation => removeOperation(operation.operation_id));
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('later');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function migrate(records) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||
enqueue('defer', itemId, wakeAt)
|
||||
);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let conflictCount = 0;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/later');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/later', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
conflictCount += (plan.rejected_operations || [])
|
||||
.filter(item => item.reason === 'stale_intent').length;
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Later delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' :
|
||||
(conflictCount ? 'conflict' : expiredCount ? 'expired' : 'saved'),
|
||||
conflictCount ? { count: conflictCount } : expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('later', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'later' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
|
||||
@@ -0,0 +1,149 @@
|
||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
|
||||
const prefix = 'stackchain.later-work.v1.';
|
||||
let timer = null;
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return {};
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function write(records) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (Object.keys(records).length) storage.setItem(key, JSON.stringify(records));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function defer(item, until) {
|
||||
const key = storageKey();
|
||||
const id = identity(item);
|
||||
const wake = new Date(until);
|
||||
if (!key) return 'unavailable';
|
||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
records[id] = wakeAt;
|
||||
if (!write(records)) return 'unavailable';
|
||||
onChange('defer', id, wakeAt);
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function deferMany(items, until) {
|
||||
const key = storageKey();
|
||||
const wake = new Date(until);
|
||||
const entries = (items || []).map(item => [identity(item), item]);
|
||||
if (!key) return 'unavailable';
|
||||
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
|
||||
return 'invalid';
|
||||
}
|
||||
const records = read();
|
||||
const wakeAt = wake.toISOString();
|
||||
entries.forEach(([id]) => { records[id] = wakeAt; });
|
||||
if (!write(records)) return 'unavailable';
|
||||
entries.forEach(([id]) => onChange('defer', id, wakeAt));
|
||||
return 'deferred';
|
||||
}
|
||||
|
||||
function presetUntil(preset) {
|
||||
const current = new Date(now());
|
||||
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
|
||||
if (preset === 'tomorrow') {
|
||||
const wake = new Date(current);
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
return wake;
|
||||
}
|
||||
return new Date(NaN);
|
||||
}
|
||||
|
||||
function restore(item) {
|
||||
const id = identity(item);
|
||||
const records = read();
|
||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||
delete records[id];
|
||||
if (!write(records)) return false;
|
||||
onChange('restore', id, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adopt(records) {
|
||||
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||
const normalized = {};
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||
});
|
||||
return write(normalized);
|
||||
}
|
||||
|
||||
function schedule(wakeTimes, current) {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
timer = null;
|
||||
if (!wakeTimes.length) return;
|
||||
const delay = Math.max(0, Math.min(...wakeTimes) - current);
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
onWake();
|
||||
}, Math.min(delay, 2147483647));
|
||||
}
|
||||
|
||||
function partition(items, { pruneMissing = true } = {}) {
|
||||
const current = now().getTime();
|
||||
const records = read();
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const retained = {};
|
||||
const wakeTimes = [];
|
||||
const expired = [];
|
||||
|
||||
Object.entries(records).forEach(([id, wake]) => {
|
||||
const wakeTime = new Date(wake).getTime();
|
||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||
return;
|
||||
}
|
||||
if (pruneMissing && !available.has(id)) return;
|
||||
retained[id] = new Date(wakeTime).toISOString();
|
||||
wakeTimes.push(wakeTime);
|
||||
});
|
||||
|
||||
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||
onExpire(expired);
|
||||
}
|
||||
schedule(wakeTimes, current);
|
||||
const deferredIds = new Set(Object.keys(retained));
|
||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||
const later = (items || []).flatMap(item => {
|
||||
const wake = retained[identity(item)];
|
||||
return wake ? [{ ...item, deferred_until: wake }] : [];
|
||||
});
|
||||
return { active, later };
|
||||
}
|
||||
|
||||
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createLoginController = factory;
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
const invalid = entries.some(([name, content]) => (
|
||||
!Object.prototype.hasOwnProperty.call(limits, name)
|
||||
|| !content
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
const form = options.form;
|
||||
const status = options.status;
|
||||
const button = options.button;
|
||||
const passkeyButton = options.passkeyButton;
|
||||
const credentials = options.credentials;
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const location = options.location;
|
||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||
const continuation = validShareContinuation(options.continuation);
|
||||
const setIntervalImpl = options.setIntervalImpl || setInterval;
|
||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||
let timer = null;
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const bytes = new Uint8Array(value);
|
||||
let binary = '';
|
||||
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function credentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||
|
||||
async function showReason(reason) {
|
||||
if (reason === 'session-expired') {
|
||||
status.textContent = 'Your session expired. Private drafts remain on this device. Sign in to continue.';
|
||||
return;
|
||||
}
|
||||
if (reason === 'session-idle') {
|
||||
status.textContent = 'Stackchain locked after inactivity. Your drafts and queued work are still on this device. Sign in to resume.';
|
||||
return;
|
||||
}
|
||||
if (reason !== 'session-revoked') return;
|
||||
button.disabled = true;
|
||||
status.textContent = 'This device was remotely signed out. Clearing Stackchain private data…';
|
||||
try {
|
||||
if (typeof clearPrivateDeviceData !== 'function') throw new Error('Private data purger unavailable.');
|
||||
await clearPrivateDeviceData();
|
||||
status.textContent = 'This device was remotely signed out. Stackchain private data was cleared. Sign in to use it again.';
|
||||
button.disabled = false;
|
||||
} catch (_error) {
|
||||
status.textContent = 'This device was remotely signed out, but private data could not be cleared. Close other Stackchain tabs and clear this site’s data before signing in.';
|
||||
}
|
||||
}
|
||||
|
||||
function showRetryCountdown(seconds) {
|
||||
let remaining = Math.max(1, Number.parseInt(seconds, 10) || 1);
|
||||
button.disabled = true;
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
timer = setIntervalImpl(() => {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearIntervalImpl(timer);
|
||||
timer = null;
|
||||
button.disabled = false;
|
||||
status.textContent = 'You can try signing in again.';
|
||||
return;
|
||||
}
|
||||
status.textContent = `Too many attempts. Try again in ${remaining} seconds.`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function submit(accessToken, deviceLabel = 'This device') {
|
||||
status.textContent = 'Signing in…';
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl('api/v1/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ access_token: accessToken, device_label: deviceLabel }),
|
||||
});
|
||||
} catch (_error) {
|
||||
form.reset();
|
||||
status.textContent = 'Sign-in failed. Check your connection and try again.';
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
if (response.ok) {
|
||||
location.replace(continuation);
|
||||
return;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
showRetryCountdown(response.headers.get('Retry-After'));
|
||||
return;
|
||||
}
|
||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
||||
}
|
||||
|
||||
async function signInWithPasskey(deviceLabel = 'This device') {
|
||||
if (!credentials?.get) {
|
||||
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
|
||||
return false;
|
||||
}
|
||||
status.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
|
||||
method: 'POST', headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: credentialJSON(credential),
|
||||
device_label: deviceLabel,
|
||||
action: 'sign_in',
|
||||
target: 'dashboard',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Passkey verification failed');
|
||||
location.replace(continuation);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
|
||||
return { submit, signInWithPasskey, showReason };
|
||||
}));
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const form = document.getElementById('sign-in');
|
||||
const status = document.getElementById('status');
|
||||
const button = document.getElementById('submit-sign-in');
|
||||
const passkeyButton = document.getElementById('passkey-sign-in');
|
||||
const loginParams = new URLSearchParams(window.location.search);
|
||||
const controller = createLoginController({
|
||||
form,
|
||||
status,
|
||||
button,
|
||||
passkeyButton,
|
||||
credentials: window.navigator?.credentials,
|
||||
fetchImpl: fetch.bind(window),
|
||||
location: window.location,
|
||||
continuation: loginParams.get('continue'),
|
||||
clearPrivateDeviceData: window.stackchainPrivateDeviceData,
|
||||
});
|
||||
controller.showReason(loginParams.get('reason'));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(form);
|
||||
const accessToken = data.get('access_token');
|
||||
const deviceLabel = data.get('device_label');
|
||||
controller.submit(accessToken, deviceLabel);
|
||||
});
|
||||
passkeyButton?.addEventListener('click', () => {
|
||||
const data = new FormData(form);
|
||||
controller.signInWithPasskey(data.get('device_label'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Stackchain Dashboard",
|
||||
"short_name": "Stackchain",
|
||||
"description": "Capture, plan, and complete Gitea work from mobile.",
|
||||
"id": "./",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#07111f",
|
||||
"theme_color": "#0b1220",
|
||||
"icons": [
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
-10
@@ -13,17 +13,95 @@
|
||||
})[character]);
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
return escapeHtml(raw)
|
||||
.replace(/^#{1,6}\s.*$/gm, (line) => {
|
||||
const level = line.match(/^(#{1,6})/)[1].length;
|
||||
return '<h' + level + '>' + line.replace(/^#{1,6}\s/, '') + '</h' + level + '>';
|
||||
function renderInline(value) {
|
||||
const code = [];
|
||||
let rendered = escapeHtml(value).replace(/`([^`\n]+)`/g, (_match, body) => {
|
||||
const marker = '\u0000CODE' + code.length + '\u0000';
|
||||
code.push('<code>' + body + '</code>');
|
||||
return marker;
|
||||
});
|
||||
rendered = rendered
|
||||
.replace(/\[([^\]\n]+)\]\(([^\s)]+)(?:\s+"[^&]*?")?\)/g, (_match, label, escapedUrl) => {
|
||||
const url = escapedUrl.replace(/&/g, '&');
|
||||
const safe = /^(https?:|mailto:)/i.test(url) || /^(\/|#|\.\/|\.\.\/)/.test(url);
|
||||
if (!safe) return label;
|
||||
return '<a href="' + escapedUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
||||
})
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/^-\s?(.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
|
||||
.replace(/\n/g, '<br>');
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index) => code[Number(index)]);
|
||||
}
|
||||
|
||||
function renderList(lines) {
|
||||
const taskList = lines.every(line => /^[-*+]\s+\[[ xX]\]\s+/.test(line));
|
||||
const items = lines.map(line => {
|
||||
let body = line.replace(/^[-*+]\s+/, '');
|
||||
if (!taskList) return '<li>' + renderInline(body) + '</li>';
|
||||
const checked = /^\[[xX]\]\s+/.test(body);
|
||||
body = body.replace(/^\[[ xX]\]\s+/, '');
|
||||
return '<li class="task-list-item"><input type="checkbox" disabled' +
|
||||
(checked ? ' checked' : '') + '> ' + renderInline(body) + '</li>';
|
||||
}).join('');
|
||||
return '<ul' + (taskList ? ' class="task-list"' : '') + '>' + items + '</ul>';
|
||||
}
|
||||
|
||||
return function renderMarkdown(raw) {
|
||||
const lines = String(raw || '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const output = [];
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
|
||||
if (fence) {
|
||||
const body = [];
|
||||
index += 1;
|
||||
while (index < lines.length && !/^```\s*$/.test(lines[index])) {
|
||||
body.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
const language = fence[1] ? ' class="language-' + fence[1] + '"' : '';
|
||||
output.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
|
||||
continue;
|
||||
}
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push('<h' + level + '>' + renderInline(heading[2]) + '</h' + level + '>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>\s?/.test(line)) {
|
||||
const quote = [];
|
||||
while (index < lines.length && /^>\s?/.test(lines[index])) {
|
||||
quote.push(lines[index].replace(/^>\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
output.push('<blockquote><p>' + renderInline(quote.join('\n')).replace(/\n/g, '<br>') + '</p></blockquote>');
|
||||
continue;
|
||||
}
|
||||
if (/^[-*+]\s+/.test(line)) {
|
||||
const list = [];
|
||||
while (index < lines.length && /^[-*+]\s+/.test(lines[index])) {
|
||||
list.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push(renderList(list));
|
||||
continue;
|
||||
}
|
||||
const paragraph = [];
|
||||
while (index < lines.length && lines[index].trim() &&
|
||||
!/^```/.test(lines[index]) && !/^(#{1,6})\s+/.test(lines[index]) &&
|
||||
!/^>\s?/.test(lines[index]) && !/^[-*+]\s+/.test(lines[index])) {
|
||||
paragraph.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
output.push('<p>' + renderInline(paragraph.join('\n')).replace(/\n/g, '<br>') + '</p>');
|
||||
}
|
||||
return output.join('');
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createMentionComposer = api.create;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function() {
|
||||
'use strict';
|
||||
|
||||
function activeMention(value, caret) {
|
||||
const before = String(value || '').slice(0, Math.max(0, caret));
|
||||
const match = before.match(/(^|\s)@([A-Za-z0-9_.-]{2,39})$/);
|
||||
if (!match) return null;
|
||||
const start = before.length - match[2].length - 1;
|
||||
return { start, end:before.length, query:match[2] };
|
||||
}
|
||||
|
||||
function insertMention(value, mention, login) {
|
||||
const text = String(value || '');
|
||||
const suffixStart = mention.end < text.length && /\s/.test(text[mention.end]) ? mention.end + 1 : mention.end;
|
||||
const inserted = '@' + login + ' ';
|
||||
return {
|
||||
value:text.slice(0, mention.start) + inserted + text.slice(suffixStart),
|
||||
caret:mention.start + inserted.length,
|
||||
};
|
||||
}
|
||||
|
||||
function create(options) {
|
||||
const textarea = options.textarea;
|
||||
const listbox = options.listbox;
|
||||
const status = options.status;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const createOption = options.createOption || (() => document.createElement('button'));
|
||||
let timer = null;
|
||||
let requestId = 0;
|
||||
let mention = null;
|
||||
let candidates = [];
|
||||
let activeIndex = -1;
|
||||
|
||||
function dismiss() {
|
||||
requestId += 1;
|
||||
candidates = [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.removeAttribute('aria-activedescendant');
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
function activate(index) {
|
||||
if (!candidates.length) return;
|
||||
activeIndex = (index + candidates.length) % candidates.length;
|
||||
listbox.children.forEach((option, optionIndex) => {
|
||||
option.setAttribute('aria-selected', optionIndex === activeIndex ? 'true' : 'false');
|
||||
});
|
||||
textarea.setAttribute('aria-activedescendant', listbox.children[activeIndex].id);
|
||||
}
|
||||
|
||||
function select(index) {
|
||||
if (!mention || !candidates[index]) return;
|
||||
const result = insertMention(textarea.value, mention, candidates[index].login);
|
||||
textarea.value = result.value;
|
||||
textarea.setSelectionRange(result.caret, result.caret);
|
||||
dismiss();
|
||||
textarea.focus();
|
||||
textarea.dispatchEvent?.(new Event('input', { bubbles:true }));
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
candidates = Array.isArray(items) ? items : [];
|
||||
activeIndex = -1;
|
||||
listbox.replaceChildren();
|
||||
candidates.forEach((candidate, index) => {
|
||||
const option = createOption();
|
||||
option.type = 'button';
|
||||
option.id = listbox.id + '-option-' + index;
|
||||
option.className = 'mention-option';
|
||||
option.dataset.login = candidate.login;
|
||||
option.setAttribute('role', 'option');
|
||||
option.setAttribute('aria-selected', 'false');
|
||||
option.textContent = '@' + candidate.login + (candidate.name !== candidate.login ? ' · ' + candidate.name : '');
|
||||
option.addEventListener('pointerdown', event => {
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
});
|
||||
listbox.appendChild(option);
|
||||
});
|
||||
listbox.hidden = !candidates.length;
|
||||
textarea.setAttribute('aria-expanded', candidates.length ? 'true' : 'false');
|
||||
status.textContent = candidates.length ? candidates.length + ' teammates found.' : 'No matching teammates.';
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer !== null) clearTimer(timer);
|
||||
mention = activeMention(textarea.value, textarea.selectionStart);
|
||||
const repository = options.getRepository();
|
||||
if (!mention || !repository) {
|
||||
dismiss();
|
||||
return;
|
||||
}
|
||||
const currentRequest = ++requestId;
|
||||
timer = setTimer(async () => {
|
||||
try {
|
||||
const items = await options.loadCandidates(repository, mention.query);
|
||||
if (currentRequest === requestId && repository === options.getRepository()) render(items);
|
||||
} catch (_error) {
|
||||
if (currentRequest === requestId) {
|
||||
listbox.hidden = true;
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
status.textContent = 'Teammate suggestions unavailable; you can keep typing.';
|
||||
}
|
||||
}
|
||||
}, options.delayMs ?? 180);
|
||||
}
|
||||
|
||||
function keydown(event) {
|
||||
if (event.key === 'Escape' && !listbox.hidden) {
|
||||
event.preventDefault();
|
||||
dismiss();
|
||||
} else if (event.key === 'ArrowDown' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex + 1);
|
||||
} else if (event.key === 'ArrowUp' && candidates.length) {
|
||||
event.preventDefault();
|
||||
activate(activeIndex < 0 ? candidates.length - 1 : activeIndex - 1);
|
||||
} else if (event.key === 'Enter' && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
select(activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
textarea.setAttribute('autocomplete', 'off');
|
||||
textarea.setAttribute('aria-autocomplete', 'list');
|
||||
textarea.setAttribute('aria-controls', listbox.id);
|
||||
textarea.setAttribute('aria-expanded', 'false');
|
||||
textarea.addEventListener('input', schedule);
|
||||
textarea.addEventListener('click', schedule);
|
||||
textarea.addEventListener('keydown', keydown);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
return { activeMention, insertMention, create };
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileComposerViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileComposerViewport(options) {
|
||||
const entries = options.entries || [];
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const documentRef = options.document || document;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = null;
|
||||
let started = false;
|
||||
|
||||
function reveal() {
|
||||
if (!active) return;
|
||||
const focused = documentRef.activeElement;
|
||||
const target = active.focusWithin && active.workspace.contains(focused) ? focused : active.workspace;
|
||||
target.scrollIntoView({ block:active.focusWithin ? 'nearest' : 'end', inline:'nearest' });
|
||||
}
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !viewport || !mediaQuery.matches) return;
|
||||
active.panel.style.setProperty('--composer-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
active.panel.style.setProperty('--composer-viewport-height', `${viewport.height}px`);
|
||||
reveal();
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
function activate(entry) {
|
||||
if (!viewport || !mediaQuery.matches) return;
|
||||
if (active === entry) {
|
||||
schedule(applyGeometry);
|
||||
return;
|
||||
}
|
||||
if (active) deactivate(active, false);
|
||||
active = entry;
|
||||
entry.scrollTop = entry.panel.scrollTop;
|
||||
entry.panel.classList?.add('composer-keyboard-active');
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
applyGeometry();
|
||||
}
|
||||
|
||||
function deactivate(entry, restore = true) {
|
||||
if (active !== entry) return;
|
||||
viewport?.removeEventListener('resize', onViewportChange);
|
||||
viewport?.removeEventListener('scroll', onViewportChange);
|
||||
entry.panel.classList?.remove('composer-keyboard-active');
|
||||
entry.panel.style.removeProperty('--composer-viewport-top');
|
||||
entry.panel.style.removeProperty('--composer-viewport-height');
|
||||
active = null;
|
||||
if (restore) schedule(() => { entry.panel.scrollTop = entry.scrollTop; });
|
||||
}
|
||||
|
||||
function bind(entry) {
|
||||
entry.onFocus = () => activate(entry);
|
||||
entry.onFocusOut = () => schedule(() => {
|
||||
if (!entry.workspace.contains(documentRef.activeElement)) deactivate(entry);
|
||||
});
|
||||
entry.focusTarget = entry.focusWithin ? entry.workspace : entry.composer;
|
||||
entry.focusEvent = entry.focusWithin ? 'focusin' : 'focus';
|
||||
entry.focusTarget.addEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.addEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
function unbind(entry) {
|
||||
entry.focusTarget.removeEventListener(entry.focusEvent, entry.onFocus);
|
||||
entry.workspace.removeEventListener('focusout', entry.onFocusOut);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
entries.forEach(bind);
|
||||
},
|
||||
close(panel) {
|
||||
const entry = entries.find(candidate => candidate.panel === panel);
|
||||
if (entry) deactivate(entry, false);
|
||||
},
|
||||
stop() {
|
||||
if (!started) return;
|
||||
if (active) deactivate(active, false);
|
||||
entries.forEach(unbind);
|
||||
started = false;
|
||||
},
|
||||
revealActive() {
|
||||
schedule(applyGeometry);
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileDeviceSetup = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
|
||||
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
|
||||
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const steps = [
|
||||
['install', options.installButton, options.installStatus, options.install],
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
|
||||
];
|
||||
let trigger = options.launcher;
|
||||
|
||||
function readinessCounts(readiness) {
|
||||
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
|
||||
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
|
||||
return {available, complete};
|
||||
}
|
||||
|
||||
function renderPrompt(readiness) {
|
||||
if (!options.promptCard) return;
|
||||
const {available, complete} = readinessCounts(readiness);
|
||||
let dismissed = false;
|
||||
try {
|
||||
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
|
||||
} catch (_error) {}
|
||||
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
|
||||
options.promptCard.hidden = dismissed || available === 0 || complete === available;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const readiness = await options.getReadiness();
|
||||
let available = 0;
|
||||
let complete = 0;
|
||||
steps.forEach(([name, button, status]) => {
|
||||
const step = readiness[name];
|
||||
status.textContent = step.detail;
|
||||
button.hidden = step.state === 'complete' || step.state === 'unavailable';
|
||||
button.disabled = step.state === 'pending';
|
||||
if (step.state !== 'unavailable') available += 1;
|
||||
if (step.state === 'complete') complete += 1;
|
||||
});
|
||||
options.readyStatus.textContent = complete === available
|
||||
? 'This device is ready.'
|
||||
: `${complete} of ${available} available steps ready.`;
|
||||
renderPrompt(readiness);
|
||||
return readiness;
|
||||
}
|
||||
|
||||
async function open(event) {
|
||||
trigger = event?.currentTarget || options.launcher;
|
||||
await render();
|
||||
options.sheet.hidden = false;
|
||||
options.closeButton.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
options.sheet.hidden = true;
|
||||
trigger?.focus?.();
|
||||
}
|
||||
|
||||
async function start() {
|
||||
options.launcher.addEventListener('click', open);
|
||||
options.promptLauncher?.addEventListener('click', open);
|
||||
options.promptDismiss?.addEventListener('click', () => {
|
||||
try {
|
||||
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
|
||||
} catch (_error) {}
|
||||
options.promptCard.hidden = true;
|
||||
});
|
||||
options.closeButton.addEventListener('click', close);
|
||||
options.sheet.addEventListener('click', event => {
|
||||
if (event.target === options.sheet) close();
|
||||
});
|
||||
options.escapeTarget.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !options.sheet.hidden) close();
|
||||
});
|
||||
steps.forEach(([_name, button, _status, action]) => {
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
try { await action(); }
|
||||
finally { await render(); }
|
||||
});
|
||||
});
|
||||
renderPrompt(await options.getReadiness());
|
||||
}
|
||||
|
||||
return {start, open, close, render};
|
||||
});
|
||||
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports.mount = mountMobileDeviceSetup;
|
||||
} else {
|
||||
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
|
||||
}
|
||||
|
||||
function mountMobileDeviceSetup(options) {
|
||||
const qs = selector => options.document.querySelector(selector);
|
||||
return createMobileDeviceSetup({
|
||||
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
promptStorage:options.promptStorage,
|
||||
escapeTarget:options.document,
|
||||
getReadiness:() => ({
|
||||
install:options.installApp.state(),
|
||||
offline:!options.offlineAvailable()
|
||||
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
|
||||
: options.offlineEnabled()
|
||||
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
push:qs('#push-updates').disabled
|
||||
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
|
||||
: qs('#push-updates').checked
|
||||
? {state:'complete', detail:'New update notifications are enabled.'}
|
||||
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
enablePush:options.enablePush,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileLaunch = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const allowedFilters = new Set([
|
||||
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
|
||||
]);
|
||||
|
||||
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
|
||||
if (allowedFilters.has(saved)) return saved;
|
||||
if (today > 0) return 'today';
|
||||
if (attention > 0) return 'attention';
|
||||
if (agenda > 0) return 'agenda';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function createDisclosure({ disclosure, launcher }) {
|
||||
function onKeydown(event) {
|
||||
if (event.key !== 'Escape' || !disclosure.open) return;
|
||||
event.preventDefault();
|
||||
disclosure.open = false;
|
||||
launcher.focus();
|
||||
}
|
||||
return {
|
||||
start() {
|
||||
disclosure.addEventListener('keydown', onKeydown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { chooseFilter, createDisclosure };
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileQueueLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
|
||||
const emptyMessages = {
|
||||
attention: 'No items need attention.',
|
||||
update: 'No unread updates are ready to open.',
|
||||
later: 'No deferred work is ready to open.',
|
||||
draft: 'No drafts are ready to open.',
|
||||
};
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
];
|
||||
|
||||
function recommend() {
|
||||
const counts = options.getCounts ? options.getCounts() : {};
|
||||
const match = continuation.find(([name]) => Number(counts[name]) > 0);
|
||||
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
|
||||
const [name, label] = match;
|
||||
const count = Math.max(0, Number(counts[name]) || 0);
|
||||
return {name, count, label: label + ' (' + count + ')'};
|
||||
}
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
options.announce(emptyMessages[name] || 'No work is ready to open.');
|
||||
return 'empty';
|
||||
}
|
||||
action.click();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
function continueWork() {
|
||||
const next = recommend();
|
||||
if (next.name === 'find') {
|
||||
options.openFindWork();
|
||||
return 'find';
|
||||
}
|
||||
return open(next.name);
|
||||
}
|
||||
|
||||
return { open, recommend, continueWork };
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
(function (root, factory) {
|
||||
const exported = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = exported;
|
||||
else root.createMobileSearchViewport = exported;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
return function createMobileSearchViewport(options) {
|
||||
const palette = options.palette;
|
||||
const results = options.results;
|
||||
const viewport = options.viewport;
|
||||
const mediaQuery = options.mediaQuery;
|
||||
const schedule = options.schedule || (callback => requestAnimationFrame(callback));
|
||||
let active = false;
|
||||
let scrollTop = 0;
|
||||
|
||||
function applyGeometry() {
|
||||
if (!active || !mediaQuery.matches || !viewport) return;
|
||||
palette.style.setProperty('--search-viewport-top', `${viewport.offsetTop || 0}px`);
|
||||
palette.style.setProperty('--search-viewport-height', `${viewport.height}px`);
|
||||
}
|
||||
|
||||
function onViewportChange() {
|
||||
schedule(applyGeometry);
|
||||
}
|
||||
|
||||
return {
|
||||
open() {
|
||||
if (active || !mediaQuery.matches || !viewport) return;
|
||||
active = true;
|
||||
applyGeometry();
|
||||
viewport.addEventListener('resize', onViewportChange);
|
||||
viewport.addEventListener('scroll', onViewportChange);
|
||||
},
|
||||
close() {
|
||||
if (active && viewport) {
|
||||
viewport.removeEventListener('resize', onViewportChange);
|
||||
viewport.removeEventListener('scroll', onViewportChange);
|
||||
}
|
||||
active = false;
|
||||
palette.style.removeProperty('--search-viewport-top');
|
||||
palette.style.removeProperty('--search-viewport-height');
|
||||
},
|
||||
rememberScroll() {
|
||||
scrollTop = results.scrollTop;
|
||||
},
|
||||
restoreScroll() {
|
||||
schedule(() => { results.scrollTop = scrollTop; });
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileTaskDock = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileTaskDock(options) {
|
||||
const nav = options.nav;
|
||||
const buttons = options.buttons;
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
|
||||
function select(name) {
|
||||
if (!buttons[name]) return false;
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
else options.sessionHud.removeAttribute('data-overlay-hidden');
|
||||
}
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
options.queueSheet?.open && options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name, row);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateWork(mode) {
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const visible = total > 0;
|
||||
if (options.attentionBadge) {
|
||||
options.attentionBadge.textContent = String(total);
|
||||
options.attentionBadge.hidden = !visible;
|
||||
}
|
||||
if (buttons.attention) {
|
||||
buttons.attention.hidden = !visible;
|
||||
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
|
||||
}
|
||||
if (visible) nav.setAttribute('data-attention', 'true');
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
options.queueRows?.update?.setAttribute(
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
options.deadlineBadge.hidden = normalized.agenda === 0;
|
||||
}
|
||||
if (options.queueRows?.agenda) {
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileWorkEntry = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'find';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
return {mode, open};
|
||||
});
|
||||
+878
-11
@@ -1,58 +1,925 @@
|
||||
function buildMyWork(data) {
|
||||
function calendarDay(value) {
|
||||
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
|
||||
if (!match) return '';
|
||||
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
||||
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
|
||||
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
|
||||
}
|
||||
|
||||
function localDay(value) {
|
||||
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(value.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function issueDueState(dueDate, now) {
|
||||
const dueDay = calendarDay(dueDate);
|
||||
if (!dueDay) return null;
|
||||
const today = localDay(now);
|
||||
if (dueDay < today) return { label: 'Overdue', priority: 2 };
|
||||
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
|
||||
return {
|
||||
label: 'Due ' + new Date(
|
||||
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
|
||||
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
priority: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function urgencyReason(item) {
|
||||
if (!item?.is_assigned || item.kind !== 'issue') return '';
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
const priorityLabel = (item.labels || []).find(label =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
if (priorityLabel) return priorityLabel + ' priority';
|
||||
return ['Overdue', 'Due today'].includes(item.due_label) ? item.due_label : '';
|
||||
}
|
||||
|
||||
function attentionReason(item) {
|
||||
if (item?.has_update) return 'Unread update';
|
||||
return urgencyReason(item) || (item?.is_review ? 'Needs your review' : '');
|
||||
}
|
||||
|
||||
function needsAttention(item) {
|
||||
return Boolean(item && (item.has_update || item.is_review || urgencyReason(item)));
|
||||
}
|
||||
|
||||
function buildMyWork(data, now = new Date()) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
return issues.concat(pulls).map((item) => {
|
||||
const work = issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
const isReview = (item.work_reasons || []).includes('review_requested');
|
||||
return {
|
||||
const due = item.kind === 'issue' ? issueDueState(item.due_date, now) : null;
|
||||
const normalized = {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
is_review: isReview,
|
||||
is_assigned: assigned,
|
||||
has_update: false,
|
||||
...(due ? { due_label: due.label } : {}),
|
||||
reason: priorityLabel ? priorityLabel + ' priority' :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work')),
|
||||
_priority: priorityLabel ? 0 : (isReview ? 1 : (assigned ? 2 : 3)),
|
||||
(due && due.priority < 4 ? due.label :
|
||||
(isReview ? 'Needs your review' : (assigned ? 'Assigned to you' : 'Open work'))),
|
||||
_priority: priorityLabel ? 0 :
|
||||
(due && due.priority < 4 ? due.priority : (isReview ? 3 : (assigned ? 4 : 5))),
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
normalized.needs_attention = needsAttention(normalized);
|
||||
normalized.attention_reason = attentionReason(normalized);
|
||||
return normalized;
|
||||
});
|
||||
|
||||
const byKey = new Map(work.map((item) => [item.kind + ':' + item.key, item]));
|
||||
(data.notifications || []).filter((item) => item && item.unread).forEach((update) => {
|
||||
const key = (update.repository || 'unknown') + '#' + update.number;
|
||||
const subjectKind = String(update.subject_type || '').toLowerCase().includes('pull') ? 'pull' : 'issue';
|
||||
const existing = byKey.get(subjectKind + ':' + key);
|
||||
if (existing) {
|
||||
existing.has_update = true;
|
||||
existing.needs_attention = true;
|
||||
existing.attention_reason = 'Unread update';
|
||||
existing.notification_id = update.id;
|
||||
existing.url = update.url || existing.url;
|
||||
existing.updated_at = update.updated_at || existing.updated_at;
|
||||
existing._priority = Math.min(existing._priority, 1);
|
||||
return;
|
||||
}
|
||||
if (!update.url) return;
|
||||
const item = {
|
||||
...update,
|
||||
key,
|
||||
kind: 'update',
|
||||
is_review: false,
|
||||
is_assigned: false,
|
||||
has_update: true,
|
||||
needs_attention: true,
|
||||
attention_reason: 'Unread update',
|
||||
notification_id: update.id,
|
||||
reason: 'Unread update',
|
||||
_priority: 1,
|
||||
};
|
||||
work.push(item);
|
||||
byKey.set(subjectKind + ':' + key, item);
|
||||
});
|
||||
|
||||
return work.sort((left, right) =>
|
||||
left._priority - right._priority ||
|
||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||
left.key.localeCompare(right.key)
|
||||
).map(({ _priority, ...item }) => item);
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter) {
|
||||
if (selectedFilter === 'all') return items;
|
||||
if (selectedFilter === 'review') return items.filter((item) => item.is_review);
|
||||
return items.filter((item) => item.kind === selectedFilter);
|
||||
function acknowledgeNotification(items, notificationId) {
|
||||
return items.flatMap((item) => {
|
||||
if (item.notification_id !== notificationId) return [item];
|
||||
if (item.kind === 'update') return [];
|
||||
const { notification_id, ...acknowledged } = item;
|
||||
const next = { ...acknowledged, has_update: false };
|
||||
if ('needs_attention' in item) next.needs_attention = needsAttention(next);
|
||||
if ('attention_reason' in item) next.attention_reason = attentionReason(next);
|
||||
return [next];
|
||||
});
|
||||
}
|
||||
|
||||
function createNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
const pending = new Set();
|
||||
return {
|
||||
async acknowledge(items, notificationId) {
|
||||
if (pending.has(notificationId)) return false;
|
||||
pending.add(notificationId);
|
||||
onItems(acknowledgeNotification(items, notificationId));
|
||||
onStatus('Marking update read…');
|
||||
try {
|
||||
await markRead(notificationId);
|
||||
onStatus('Update marked read.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onItems(items);
|
||||
onStatus('Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(notificationId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function notificationIds(items) {
|
||||
return Array.from(new Set((items || [])
|
||||
.filter((item) => item && item.has_update && Number.isInteger(item.notification_id))
|
||||
.map((item) => item.notification_id)));
|
||||
}
|
||||
|
||||
function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
let pending = false;
|
||||
return {
|
||||
async acknowledge(items, requestedIds) {
|
||||
if (pending) return false;
|
||||
const ids = Array.from(new Set((requestedIds || []).filter(Number.isInteger)));
|
||||
if (!ids.length) return false;
|
||||
pending = true;
|
||||
onStatus('Marking ' + ids.length + ' updates read…');
|
||||
try {
|
||||
const result = await markRead(ids);
|
||||
const marked = (result.marked || []).filter(Number.isInteger);
|
||||
const failed = (result.failed || []).filter(Number.isInteger);
|
||||
const updated = marked.reduce(
|
||||
(current, notificationId) => acknowledgeNotification(current, notificationId),
|
||||
items
|
||||
);
|
||||
onItems(updated);
|
||||
onStatus(failed.length ?
|
||||
marked.length + ' marked read · ' + failed.length + ' could not be updated — retry.' :
|
||||
marked.length + ' updates marked read.');
|
||||
return { marked, failed };
|
||||
} catch (_error) {
|
||||
onStatus('Could not mark updates read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
ids: Array.from(selected),
|
||||
count: selected.size,
|
||||
limit: maximum,
|
||||
at_limit: selected.size >= maximum,
|
||||
});
|
||||
const publish = () => onChange(snapshot());
|
||||
const select = notificationId => {
|
||||
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||
if (selected.has(notificationId)) return 'already-selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(notificationId);
|
||||
publish();
|
||||
return 'selected';
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (active) return snapshot();
|
||||
active = true;
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
select,
|
||||
toggle(notificationId) {
|
||||
if (!selected.has(notificationId)) return select(notificationId);
|
||||
selected.delete(notificationId);
|
||||
publish();
|
||||
return 'deselected';
|
||||
},
|
||||
retain(notificationIds) {
|
||||
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||
let changed = false;
|
||||
selected.forEach(id => {
|
||||
if (!allowed.has(id)) {
|
||||
selected.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) publish();
|
||||
return snapshot();
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
},
|
||||
async loadMore(existing) {
|
||||
if (pending || !pagination.has_more) return false;
|
||||
pending = true;
|
||||
onStatus('Loading older updates…');
|
||||
try {
|
||||
const result = await load(pagination.page + 1);
|
||||
const byId = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, item]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (item && Number.isInteger(item.id) && !byId.has(item.id)) byId.set(item.id, item);
|
||||
});
|
||||
pagination = {
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
has_more: result.has_more === true,
|
||||
};
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
onNotifications(Array.from(byId.values()));
|
||||
onPagination(pagination);
|
||||
onStatus(loaded + ' of ' + pagination.total + ' unread updates loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older updates. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
let pagination = {};
|
||||
const pending = new Set();
|
||||
const completing = new Map();
|
||||
const labels = {
|
||||
issue: 'issues',
|
||||
pull: 'pull requests',
|
||||
review: 'review requests',
|
||||
};
|
||||
const pager = {
|
||||
reset(next) {
|
||||
Object.entries(next || {}).forEach(([stream, value]) => {
|
||||
const current = pagination[stream];
|
||||
pagination[stream] = current && current.page > 1 ?
|
||||
{ ...value, page: current.page, has_more: current.page * 50 < value.total } :
|
||||
{ ...value };
|
||||
});
|
||||
onPagination({ ...pagination });
|
||||
},
|
||||
async loadMore(stream, existing) {
|
||||
const page = pagination[stream];
|
||||
if (pending.has(stream) || !page?.has_more) return false;
|
||||
pending.add(stream);
|
||||
const label = labels[stream] || 'work';
|
||||
onStatus('Loading older ' + label + '…');
|
||||
try {
|
||||
const result = await load(stream, page.page + 1);
|
||||
const merged = new Map((existing || [])
|
||||
.filter(item => item && Number.isInteger(item.id))
|
||||
.map(item => [item.id, { ...item }]));
|
||||
(result.items || []).forEach(item => {
|
||||
if (!item || !Number.isInteger(item.id)) return;
|
||||
const current = merged.get(item.id);
|
||||
if (!current) {
|
||||
merged.set(item.id, { ...item });
|
||||
return;
|
||||
}
|
||||
const reasons = Array.from(new Set(
|
||||
(current.work_reasons || []).concat(item.work_reasons || [])
|
||||
));
|
||||
merged.set(item.id, {
|
||||
...current, ...item, ...(reasons.length ? { work_reasons: reasons } : {}),
|
||||
});
|
||||
});
|
||||
pagination[stream] = {
|
||||
page: result.page, total: result.total, has_more: result.has_more === true,
|
||||
};
|
||||
onItems(stream, Array.from(merged.values()));
|
||||
onPagination({ ...pagination });
|
||||
onStatus(Math.min(result.total, result.page * 50) + ' of ' + result.total + ' ' + label + ' loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not load older ' + label + '. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending.delete(stream);
|
||||
}
|
||||
},
|
||||
loadAll(stream, getExisting) {
|
||||
if (completing.has(stream)) return completing.get(stream);
|
||||
const completion = (async () => {
|
||||
while (pagination[stream]?.has_more) {
|
||||
const loaded = await pager.loadMore(stream, getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => completing.delete(stream));
|
||||
completing.set(stream, completion);
|
||||
return completion;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createNotificationReader({
|
||||
load, markRead, onOpen, onDetail, onItems, onStatus, onClose,
|
||||
acknowledge = null,
|
||||
queueRead = null,
|
||||
loadSaved = () => null,
|
||||
loadConversation = null,
|
||||
onConversation = () => {},
|
||||
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
|
||||
}) {
|
||||
let selected = null;
|
||||
let loadVersion = 0;
|
||||
let marking = false;
|
||||
let conversationPager = null;
|
||||
let offlineHydrated = false;
|
||||
|
||||
async function advanceAfterRead(items, current, queueing = false) {
|
||||
const updated = acknowledgeNotification(items, current.notification_id);
|
||||
onItems(updated);
|
||||
const currentIndex = items.indexOf(current);
|
||||
const remaining = items.slice(currentIndex + 1).concat(items.slice(0, currentIndex));
|
||||
const next = remaining.find(item => item && item.has_update &&
|
||||
Number.isInteger(item.notification_id) && (!queueing || loadSaved(item)));
|
||||
if (next) await open(next, queueing ? loadSaved(next) : null);
|
||||
else {
|
||||
selected = null;
|
||||
onClose();
|
||||
onStatus('Inbox cleared.');
|
||||
}
|
||||
return { items: updated, item: current, next: next || null };
|
||||
}
|
||||
|
||||
async function open(item, savedDetail = null) {
|
||||
selected = item;
|
||||
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
|
||||
const version = ++loadVersion;
|
||||
onOpen(item);
|
||||
onStatus('Loading update…');
|
||||
try {
|
||||
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
|
||||
if (selected !== item || version !== loadVersion) return false;
|
||||
onDetail(detail);
|
||||
if (createPager && loadConversation && detail.conversation) {
|
||||
conversationPager = createPager({
|
||||
loadPage: page => loadConversation(item.notification_id, page),
|
||||
});
|
||||
onConversation(conversationPager.reset(detail.conversation));
|
||||
} else {
|
||||
conversationPager = null;
|
||||
}
|
||||
onStatus('Update ready.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (selected === item && version === loadVersion) {
|
||||
onStatus('Could not load update. Retry or open it in Gitea.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
commentPager() {
|
||||
return conversationPager;
|
||||
},
|
||||
appendReply(comment) {
|
||||
if (!conversationPager) return false;
|
||||
onConversation(conversationPager.append(comment));
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
if (!conversationPager || !selected || offlineHydrated) return false;
|
||||
const pager = conversationPager;
|
||||
const version = loadVersion;
|
||||
onStatus('Loading older messages…');
|
||||
try {
|
||||
const state = await pager.loadOlder();
|
||||
if (pager !== conversationPager || version !== loadVersion) return false;
|
||||
onConversation(state);
|
||||
onStatus(state.comments.length + ' of ' + state.total + ' messages loaded.');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (pager === conversationPager && version === loadVersion) {
|
||||
onStatus('Could not load older messages. Retry.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async markReadAndNext(items) {
|
||||
if (!selected || marking || (offlineHydrated && !queueRead)) return false;
|
||||
const current = selected;
|
||||
const queueing = offlineHydrated;
|
||||
marking = true;
|
||||
onStatus(queueing ? 'Queueing update read…' : 'Marking update read…');
|
||||
try {
|
||||
if (queueing) await queueRead(current.notification_id);
|
||||
else await markRead(current.notification_id);
|
||||
return await advanceAfterRead(items, current, queueing);
|
||||
} catch (_error) {
|
||||
onStatus(queueing ? 'Could not queue update read. Retry.' : 'Could not mark update read. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
async acknowledgeAndNext(items) {
|
||||
if (!selected || marking || offlineHydrated || !acknowledge) return false;
|
||||
const current = selected;
|
||||
marking = true;
|
||||
onStatus('Adding reaction and marking read…');
|
||||
try {
|
||||
await acknowledge(current.notification_id);
|
||||
return await advanceAfterRead(items, current);
|
||||
} catch (_error) {
|
||||
onStatus('Could not acknowledge update. Retry.');
|
||||
return false;
|
||||
} finally {
|
||||
marking = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationReplier({
|
||||
post, storage, onStatus, authoredOutbox,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pending = false;
|
||||
const keyFor = item => 'stackchain.update-reply.v1.' + item.notification_id;
|
||||
const operationKeyFor = item => keyFor(item) + ':operation';
|
||||
return {
|
||||
loadDraft(item) {
|
||||
try { return storage.getItem(keyFor(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
|
||||
storage.setItem(keyFor(item), body);
|
||||
}
|
||||
catch (_error) { /* Keep the editable textarea as the fallback. */ }
|
||||
},
|
||||
async submit(item, body, attachment = null) {
|
||||
if (pending) return false;
|
||||
pending = true;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage.getItem(operationKeyFor(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage.setItem(operationKeyFor(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
onStatus('Sending reply…');
|
||||
try {
|
||||
if (attachment) {
|
||||
onStatus('Saving screenshot for durable delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind:'update-reply', notificationId:item.notification_id, body, operationId, attachment,
|
||||
});
|
||||
const delivery = await authoredOutbox.retry(admission.item.id, admission.item.ownerLogin);
|
||||
if (!delivery.confirmed?.length) {
|
||||
const remaining = delivery.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') return false;
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued:true };
|
||||
}
|
||||
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* Confirmed delivery is authoritative. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return delivery.confirmed[0];
|
||||
}
|
||||
const result = await post(item.notification_id, body, operationId);
|
||||
try { storage.removeItem(keyFor(item)); }
|
||||
catch (_error) { /* The posted reply is still authoritative. */ }
|
||||
try { storage.removeItem(operationKeyFor(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
onStatus('Reply posted. You can mark this update read when ready.');
|
||||
return result;
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (authoredOutbox && (!status || status >= 500)) {
|
||||
onStatus('Saving for background delivery…');
|
||||
const admission = await authoredOutbox.enqueueDurably({
|
||||
kind: 'update-reply', notificationId: item.notification_id, body, operationId,
|
||||
});
|
||||
if (admission.background) {
|
||||
onStatus('Queued for sync when the connection returns.');
|
||||
return { queued: true };
|
||||
}
|
||||
onStatus('Saved for next launch; background delivery unavailable.');
|
||||
return { queued: false, degraded: true };
|
||||
}
|
||||
onStatus('Could not send reply. Your draft is safe; retry.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
|
||||
let filtered = items;
|
||||
if (selectedFilter === 'attention') filtered = items.filter(needsAttention);
|
||||
else if (selectedFilter === 'review') filtered = items.filter((item) => item.is_review);
|
||||
else if (selectedFilter === 'update') filtered = items.filter((item) => item.has_update);
|
||||
else if (selectedFilter !== 'all') filtered = items.filter((item) => item.kind === selectedFilter);
|
||||
if (selectedMilestone === 'all') return filtered;
|
||||
if (selectedMilestone === 'unplanned') {
|
||||
return filtered.filter(item => item.kind === 'issue' && !item.milestone);
|
||||
}
|
||||
return filtered.filter(item =>
|
||||
item.kind === 'issue' && String(item.milestone?.id || '') === String(selectedMilestone)
|
||||
);
|
||||
}
|
||||
|
||||
function agendaMyWork(items, now = new Date()) {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const today = localDay(start);
|
||||
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||
const tomorrow = localDay(tomorrowDate);
|
||||
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
|
||||
const afterTomorrow = localDay(afterTomorrowDate);
|
||||
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
|
||||
const horizon = localDay(horizonDate);
|
||||
return (items || []).flatMap(item => {
|
||||
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
|
||||
const due = calendarDay(item.due_date);
|
||||
if (!due || due >= horizon) return [];
|
||||
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
|
||||
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
|
||||
return [{ ...item, agenda_group: group, _agenda_due: due }];
|
||||
}).sort((left, right) =>
|
||||
left._agenda_due.localeCompare(right._agenda_due) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
).map(({ _agenda_due, ...item }) => item);
|
||||
}
|
||||
|
||||
function milestoneLanes(items) {
|
||||
const lanes = new Map();
|
||||
(items || []).forEach(item => {
|
||||
if (item?.kind === 'issue' && Number.isInteger(item.milestone?.id) &&
|
||||
typeof item.milestone?.title === 'string') {
|
||||
lanes.set(item.milestone.id, { id: item.milestone.id, title: item.milestone.title });
|
||||
}
|
||||
});
|
||||
return Array.from(lanes.values()).sort((left, right) => left.title.localeCompare(right.title));
|
||||
}
|
||||
|
||||
function workIdentity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const repository = item.repository || '';
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, repository, number, notification].join(':');
|
||||
}
|
||||
|
||||
function createWorkSessionCheckpoint({
|
||||
storage,
|
||||
getLogin,
|
||||
onError = () => {},
|
||||
key = 'stackchain.today-session.v1',
|
||||
}) {
|
||||
const login = () => String(getLogin() || '').trim();
|
||||
let errorReported = false;
|
||||
const reportError = error => {
|
||||
if (errorReported) return;
|
||||
errorReported = true;
|
||||
onError(error);
|
||||
};
|
||||
const read = () => {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
|
||||
!Number.isInteger(value.index) || value.index < 0) return null;
|
||||
return value;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
read,
|
||||
save(identity, index) {
|
||||
const owner = login();
|
||||
if (!owner) return false;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
if (!read()) return false;
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
reportError(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
index: index + 1,
|
||||
total: items.length,
|
||||
can_previous: index > 0,
|
||||
can_next: index < items.length - 1,
|
||||
});
|
||||
const finish = () => {
|
||||
const clearCheckpoint = durable;
|
||||
running = false;
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
const openAt = (items, index) => {
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const requestedIdentity = requested ? workIdentity(requested) : currentIdentity;
|
||||
const index = items.findIndex(item => workIdentity(item) === requestedIdentity);
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
const exact = items.findIndex(item => workIdentity(item) ===
|
||||
(requested ? workIdentity(requested) : saved.identity));
|
||||
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
|
||||
},
|
||||
start(item = null) {
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
reconcile() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (index < 0) return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
currentIndex = index;
|
||||
report(items, index);
|
||||
return true;
|
||||
},
|
||||
previous() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
return index > 0 ? openAt(items, index - 1) : false;
|
||||
},
|
||||
next(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
return index >= 0 && index < items.length - 1 ? openAt(items, index + 1) : finish();
|
||||
},
|
||||
complete(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
if (requested) {
|
||||
const requestedIndex = items.findIndex(item => workIdentity(item) === workIdentity(requested));
|
||||
return requestedIndex >= 0 ? openAt(items, requestedIndex) : false;
|
||||
}
|
||||
const stillPresent = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (stillPresent >= 0) {
|
||||
return stillPresent < items.length - 1 ? openAt(items, stillPresent + 1) : finish();
|
||||
}
|
||||
return items.length ? openAt(items, Math.min(currentIndex, items.length - 1)) : finish();
|
||||
},
|
||||
items: () => queue().slice(),
|
||||
target(action) {
|
||||
const items = queue();
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
}
|
||||
const current = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (action === 'continue') return current >= 0 ? items[current] : null;
|
||||
if (action === 'next') return current >= 0 && current < items.length - 1 ? items[current + 1] : null;
|
||||
if (action === 'complete') {
|
||||
return items[current >= 0 ? Math.min(current + 1, items.length - 1) : Math.min(currentIndex, items.length - 1)];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueLabels(data, repository, number, labels) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, labels: [...labels] } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueContent(data, repository, number, content) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, ...content } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueDueDate(data, repository, number, dueDate) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, due_date: dueDate } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function replaceIssueMilestone(data, repository, number, milestone) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).map(item =>
|
||||
item.repository === repository && item.number === number ? { ...item, milestone } : item
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function removeIssue(data, repository, number) {
|
||||
return {
|
||||
...data,
|
||||
issues: (data.issues || []).filter(item =>
|
||||
item.repository !== repository || item.number !== number
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMyWork(items) {
|
||||
const updates = items.filter((item) => item.has_update).length;
|
||||
const reviews = items.filter((item) => item.is_review).length;
|
||||
const assigned = items.filter((item) => item.is_assigned).length;
|
||||
const updateLabel = updates + ' unread update' + (updates === 1 ? '' : 's');
|
||||
const reviewLabel = reviews + ' review' + (reviews === 1 ? '' : 's');
|
||||
const assignedLabel = assigned + ' assigned';
|
||||
return reviewLabel + ' · ' + assignedLabel;
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
attention: items.filter(needsAttention).length,
|
||||
issue: items.filter((item) => item.kind === 'issue').length,
|
||||
pull: items.filter((item) => item.kind === 'pull' && !item.is_review).length,
|
||||
review: items.filter((item) => item.is_review).length,
|
||||
update: items.filter((item) => item.has_update).length,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.filterMyWork = filterMyWork;
|
||||
buildMyWork.agendaMyWork = agendaMyWork;
|
||||
buildMyWork.milestoneLanes = milestoneLanes;
|
||||
buildMyWork.createWorkSession = createWorkSession;
|
||||
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
|
||||
buildMyWork.workIdentity = workIdentity;
|
||||
buildMyWork.replaceIssueLabels = replaceIssueLabels;
|
||||
buildMyWork.replaceIssueContent = replaceIssueContent;
|
||||
buildMyWork.replaceIssueDueDate = replaceIssueDueDate;
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
buildMyWork.createNotificationReplier = createNotificationReplier;
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
function createNotificationReadOutbox({
|
||||
storage, fetchJson, backgroundSync, coordinator, getOwnerLogin = () => '', now = () => Date.now(), maxItems = 100,
|
||||
}) {
|
||||
const storageKey = 'stackchain.notification-read-outbox.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item => item?.kind === 'notification-read' &&
|
||||
Number.isInteger(item.notificationId) && item.notificationId > 0 && item.ownerLogin);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items, mirror = true) {
|
||||
storage?.setItem(storageKey, JSON.stringify({ version: 1, items }));
|
||||
coordinator?.notify('notification-read');
|
||||
if (mirror && backgroundSync?.reconcile) {
|
||||
Promise.resolve(backgroundSync.reconcile(items, 'notification-read'))
|
||||
.then(() => items.length ? backgroundSync.requestSync?.() : undefined)
|
||||
.catch(() => { /* Foreground reconnect remains available. */ });
|
||||
}
|
||||
}
|
||||
|
||||
function itemId(ownerLogin, notificationId) {
|
||||
return 'notification-read:' + ownerLogin + ':' + notificationId;
|
||||
}
|
||||
|
||||
async function enqueueDurably(notificationId) {
|
||||
notificationId = Number(notificationId);
|
||||
const ownerLogin = String(getOwnerLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before queueing this update.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid update.');
|
||||
const items = read();
|
||||
const id = itemId(ownerLogin, notificationId);
|
||||
let item = items.find(candidate => candidate.id === id);
|
||||
if (!item) {
|
||||
if (items.length >= maxItems) throw new Error('Update acknowledgement queue is full. Reconnect before clearing more updates.');
|
||||
item = {
|
||||
id, kind: 'notification-read', notificationId, ownerLogin,
|
||||
status: 'queued', queuedAt: Number(now()),
|
||||
};
|
||||
items.push(item);
|
||||
write(items, false);
|
||||
}
|
||||
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only' };
|
||||
}
|
||||
try {
|
||||
await backgroundSync.reconcile(read(), 'notification-read');
|
||||
await backgroundSync.requestSync();
|
||||
return { item: { ...item }, background: true, durability: 'background' };
|
||||
} catch (error) {
|
||||
return { item: { ...item }, background: false, durability: 'foreground-only', error };
|
||||
}
|
||||
}
|
||||
|
||||
function suppress(items, login = getOwnerLogin()) {
|
||||
const pending = new Set(read().filter(item => item.ownerLogin === String(login || '').trim())
|
||||
.map(item => item.notificationId));
|
||||
return (items || []).filter(item => !pending.has(Number(item?.notification_id ?? item?.id)));
|
||||
}
|
||||
|
||||
async function send(item, currentLogin) {
|
||||
if (!currentLogin || item.ownerLogin !== currentLogin) return { blocked: true };
|
||||
if (backgroundSync?.send) return backgroundSync.send(item, currentLogin);
|
||||
await fetchJson('api/v1/notifications/' + encodeURIComponent(item.notificationId) + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { confirmed: true };
|
||||
}
|
||||
|
||||
async function flush(currentLogin) {
|
||||
currentLogin = String(currentLogin || '').trim();
|
||||
const confirmed = [];
|
||||
let items = read();
|
||||
for (const item of items) {
|
||||
if (item.ownerLogin !== currentLogin || item.status === 'attention') continue;
|
||||
try {
|
||||
const result = await send(item, currentLogin);
|
||||
if (result?.blocked || result?.busy) continue;
|
||||
if (result?.attention) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(result.error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
confirmed.push(item.notificationId);
|
||||
items = items.filter(candidate => candidate.id !== item.id);
|
||||
write(items);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status >= 400 && status < 500) {
|
||||
items = items.map(candidate => candidate.id === item.id ? {
|
||||
...candidate, status: 'attention', error: String(error?.message || 'Update needs attention').slice(0, 240),
|
||||
} : candidate);
|
||||
write(items);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { confirmed, remaining: read() };
|
||||
}
|
||||
|
||||
function reconcileBackground(records) {
|
||||
const states = new Map((records || []).filter(item => item?.kind === 'notification-read')
|
||||
.map(item => [item.id, item]));
|
||||
const items = read().flatMap(item => {
|
||||
const state = states.get(item.id);
|
||||
if (state?.status === 'sent') return [];
|
||||
if (state?.status === 'attention') return [{
|
||||
...item, status: 'attention', error: String(state.error || 'Update needs attention').slice(0, 240),
|
||||
}];
|
||||
return [item];
|
||||
});
|
||||
write(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
return { enqueueDurably, flush, suppress, reconcileBackground, list: () => read().map(item => ({ ...item })) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createNotificationReadOutbox;
|
||||
@@ -0,0 +1,85 @@
|
||||
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
|
||||
let offered = null;
|
||||
let pending = false;
|
||||
|
||||
return {
|
||||
offer(item, items) {
|
||||
if (!item || !Number.isInteger(item.notification_id)) return false;
|
||||
offered = { item, items: Array.isArray(items) ? [...items] : [] };
|
||||
const label = item.key || 'Update';
|
||||
onStatus(label + ' marked read. Undo?');
|
||||
onOffer(item);
|
||||
return true;
|
||||
},
|
||||
async run() {
|
||||
if (!offered || pending) return false;
|
||||
const current = offered;
|
||||
const label = current.item.key || 'Update';
|
||||
pending = true;
|
||||
onStatus('Restoring ' + label + '…');
|
||||
try {
|
||||
await restore(current.item.notification_id);
|
||||
if (offered !== current) return false;
|
||||
const restored = [current.item].concat(current.items.filter(
|
||||
item => item?.notification_id !== current.item.notification_id
|
||||
));
|
||||
offered = null;
|
||||
onItems(restored, current.item);
|
||||
onStatus(label + ' is unread again.');
|
||||
onClear();
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus('Could not restore ' + label + '. Retry Undo.');
|
||||
return false;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
offered = null;
|
||||
onClear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function requestNotificationUnread(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
|
||||
let timer = null;
|
||||
const controller = createNotificationUndo({
|
||||
restore,
|
||||
onItems: (items, restored) => {
|
||||
setItems(items);
|
||||
const notifications = getNotifications();
|
||||
if (!notifications.some(item => item.id === restored.notification_id)) {
|
||||
setNotifications([{ id:restored.notification_id }].concat(notifications));
|
||||
}
|
||||
refresh();
|
||||
},
|
||||
onStatus: message => {
|
||||
select('#notification-undo-status').textContent = message;
|
||||
select('#my-work-action-status').textContent = message;
|
||||
},
|
||||
onOffer: item => {
|
||||
clearTimeout(timer);
|
||||
select('#notification-undo').hidden = false;
|
||||
select('#undo-notification').disabled = false;
|
||||
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
|
||||
timer = setTimeout(() => controller.clear(), 10000);
|
||||
},
|
||||
onClear: () => { select('#notification-undo').hidden = true; },
|
||||
});
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createNotificationUndo;
|
||||
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
function createOfflineIssueBlocker({
|
||||
enqueueDurably,
|
||||
onQueued = () => {},
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
}) {
|
||||
return async function queueOfflineIssueBlocker(item, blocker, present) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-blocker',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
blockerRepository: String(blocker.repository || ''),
|
||||
blockerNumber: Number(blocker.number || 0),
|
||||
present: present === true,
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
onQueued(item, blocker, present === true, admission);
|
||||
return { admission };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueBlocker;
|
||||
@@ -0,0 +1,19 @@
|
||||
function createOfflineIssueClose({ enqueueDurably, completeToday, createOperationId = () =>
|
||||
globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2) }) {
|
||||
return async function closeOfflineIssue(item) {
|
||||
const admission = await enqueueDurably({
|
||||
kind: 'issue-close',
|
||||
repository: String(item.repository || ''),
|
||||
number: Number(item.number || 0),
|
||||
body: '',
|
||||
operationId: String(createOperationId()).slice(0, 128),
|
||||
});
|
||||
const advanced = Boolean(completeToday(item, {
|
||||
successMessage: 'Issue closure queued. Next Today item opened.',
|
||||
failureMessage: 'Issue closure queued, but Today still needs completion.',
|
||||
}));
|
||||
return { admission, advanced };
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOfflineIssueClose;
|
||||
@@ -0,0 +1,88 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineToday = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const itemKey = item => [item?.kind, item?.repository, Number(item?.number || 0)].join(':');
|
||||
|
||||
function createOfflineToday({
|
||||
loadDetail, loadSavedDetail, saveDetail, onStatus = () => {}, concurrency = 2, maxItems = 5,
|
||||
}) {
|
||||
let failed = new Set();
|
||||
let generation = 0;
|
||||
|
||||
function bounded(items) {
|
||||
return (Array.isArray(items) ? items : []).filter(item =>
|
||||
['issue', 'pull'].includes(item?.kind) && item?.repository && Number(item?.number) > 0
|
||||
).slice(0, Math.max(1, maxItems));
|
||||
}
|
||||
|
||||
async function run(login, sourceItems, onlyFailed) {
|
||||
login = String(login || '').trim();
|
||||
const items = bounded(sourceItems);
|
||||
const runGeneration = ++generation;
|
||||
if (!login || !items.length) {
|
||||
failed = new Set();
|
||||
const empty = { total: items.length, ready: 0, failed: 0, pending: 0 };
|
||||
onStatus(empty);
|
||||
return empty;
|
||||
}
|
||||
|
||||
const previousFailed = failed;
|
||||
const savedDetails = new Map();
|
||||
await Promise.all(items.map(async item => {
|
||||
savedDetails.set(itemKey(item), await loadSavedDetail(login, item));
|
||||
}));
|
||||
const readyKeys = new Set(items.filter(item => savedDetails.get(itemKey(item))).map(itemKey));
|
||||
const snapshot = pending => ({
|
||||
total: items.length, ready: readyKeys.size, failed: failed.size, pending,
|
||||
});
|
||||
const candidates = items.filter(item => {
|
||||
const key = itemKey(item);
|
||||
if (onlyFailed && !previousFailed.has(key)) return false;
|
||||
const saved = savedDetails.get(key);
|
||||
return onlyFailed || !saved || saved.source_updated_at !== item.updated_at;
|
||||
});
|
||||
failed = new Set();
|
||||
onStatus(snapshot(candidates.length));
|
||||
let cursor = 0;
|
||||
|
||||
async function worker() {
|
||||
while (cursor < candidates.length && runGeneration === generation) {
|
||||
const item = candidates[cursor++];
|
||||
try {
|
||||
const detail = await loadDetail(item);
|
||||
if (runGeneration !== generation) return;
|
||||
const saved = await saveDetail(login, item, { ...detail, source_updated_at: item.updated_at });
|
||||
if (saved === false) throw new Error('Offline detail was not durably admitted.');
|
||||
readyKeys.add(itemKey(item));
|
||||
} catch (_error) {
|
||||
if (runGeneration === generation) failed.add(itemKey(item));
|
||||
}
|
||||
if (runGeneration === generation) {
|
||||
onStatus(snapshot(Math.max(0, candidates.length - cursor)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length: Math.min(Math.max(1, concurrency), candidates.length) }, worker
|
||||
));
|
||||
if (runGeneration !== generation) return snapshot(0);
|
||||
const status = snapshot(0);
|
||||
onStatus(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
return {
|
||||
warm: (login, items) => run(login, items, false),
|
||||
retry: (login, items) => run(login, items, true),
|
||||
cancel() { generation += 1; failed = new Set(); },
|
||||
failedKeys: () => Array.from(failed),
|
||||
};
|
||||
}
|
||||
|
||||
return createOfflineToday;
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.createOfflineWorkStore = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
'use strict';
|
||||
|
||||
const ENABLED_KEY = 'stackchain.offline-work.enabled.v1';
|
||||
const SNAPSHOT_KEY = 'stackchain.offline-work.snapshot.v1';
|
||||
const DETAILS_KEY = 'stackchain.offline-work.details.v1';
|
||||
const VERSION = 1;
|
||||
const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ITEM_FIELDS = [
|
||||
'id', 'number', 'title', 'state', 'repository', 'labels', 'assignees',
|
||||
'updated_at', 'due_date', 'milestone', 'url', 'work_reasons',
|
||||
];
|
||||
const NOTIFICATION_FIELDS = [
|
||||
'id', 'number', 'title', 'unread', 'repository', 'subject_type', 'updated_at', 'url',
|
||||
];
|
||||
const DETAIL_FIELDS = [
|
||||
'title', 'body', 'state', 'labels', 'assignees', 'author', 'url', 'due_date', 'milestone',
|
||||
'id', 'repository', 'subject_type', 'subject_body', 'source_updated_at', 'head_sha', 'ci_state',
|
||||
];
|
||||
const COMMENT_FIELDS = ['id', 'author', 'body', 'created_at', 'updated_at', 'url'];
|
||||
const REVIEW_FILE_FIELDS = [
|
||||
'filename', 'status', 'additions', 'deletions', 'diff_available', 'diff_binary', 'diff_truncated',
|
||||
];
|
||||
const REVIEW_FIELDS = ['id', 'state', 'body', 'submitted_at'];
|
||||
|
||||
function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-offline-work-v2') {
|
||||
if (!indexedDB) return null;
|
||||
let databasePromise;
|
||||
function database() {
|
||||
if (!databasePromise) databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains('work')) {
|
||||
request.result.createObjectStore('work', { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => { db.close(); databasePromise = undefined; };
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Offline work database failed to open.'));
|
||||
});
|
||||
return databasePromise;
|
||||
}
|
||||
const requested = request => new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return async work => {
|
||||
const db = await database();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('work', 'readwrite');
|
||||
const store = transaction.objectStore('work');
|
||||
let result;
|
||||
let workError;
|
||||
transaction.oncomplete = () => workError ? undefined : resolve(result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(workError || transaction.error || new Error('Offline work transaction aborted.'));
|
||||
Promise.resolve(work({
|
||||
get: key => requested(store.get(key)),
|
||||
getAll: () => requested(store.getAll()),
|
||||
put: value => requested(store.put(value)),
|
||||
delete: key => requested(store.delete(key)),
|
||||
clear: () => requested(store.clear()),
|
||||
})).then(value => { result = value; }).catch(error => {
|
||||
workError = error;
|
||||
try { transaction.abort(); } catch (_) { reject(error); }
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function pick(source, fields) {
|
||||
const output = {};
|
||||
fields.forEach(field => {
|
||||
if (source && source[field] !== undefined) output[field] = source[field];
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function createOfflineWorkStore({
|
||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||
maxDetails = 10, initializationTimeoutMs = 1500,
|
||||
}) {
|
||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||
let migrationPromise;
|
||||
let readinessPromise;
|
||||
let readinessGeneration = 0;
|
||||
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
|
||||
let recordCache = null;
|
||||
let pendingDurableClear = false;
|
||||
|
||||
function hydrateLegacyCache() {
|
||||
const cache = new Map();
|
||||
try {
|
||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
if (Array.isArray(details)) details.forEach(record => {
|
||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
cache.set(id, { ...record, id });
|
||||
});
|
||||
} catch (_) { cache.clear(); }
|
||||
recordCache = cache;
|
||||
}
|
||||
|
||||
function migrateLegacy(generation) {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
if (!migrationPromise) migrationPromise = (async () => {
|
||||
let snapshot = null;
|
||||
let details = [];
|
||||
try {
|
||||
snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||
const parsedDetails = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
details = Array.isArray(parsedDetails) ? parsedDetails : [];
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
if (!snapshot && !details.length) return true;
|
||||
await transact(async records => {
|
||||
if (snapshot && !(await records.get('snapshot'))) {
|
||||
await records.put({ ...snapshot, id:'snapshot' });
|
||||
}
|
||||
for (const record of details.slice(-Math.max(1, maxDetails))) {
|
||||
if (!record?.key || !record?.user_login || !record?.data) continue;
|
||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||
}
|
||||
});
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
return migrationPromise;
|
||||
}
|
||||
|
||||
async function initialize(generation) {
|
||||
const migrated = await migrateLegacy(generation);
|
||||
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
if (pendingDurableClear) {
|
||||
await transact(async records => { await records.clear(); });
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
pendingDurableClear = false;
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}
|
||||
return transact(async records => {
|
||||
const currentTime = now().getTime();
|
||||
const valid = [];
|
||||
for (const record of await records.getAll()) {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const validShape = record?.id === 'snapshot' ?
|
||||
record.version === VERSION && record.user_login && record.data :
|
||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||
if (record?.id) await records.delete(record.id);
|
||||
} else valid.push(record);
|
||||
}
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function startReady() {
|
||||
const generation = ++readinessGeneration;
|
||||
readinessState = { state:'initializing', reason:'' };
|
||||
const operation = initialize(generation).catch(() => false);
|
||||
readinessPromise = new Promise(resolve => {
|
||||
const timer = setTimeout(() => {
|
||||
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'deadline' };
|
||||
resolve(false);
|
||||
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
|
||||
operation.then(available => {
|
||||
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
|
||||
clearTimeout(timer);
|
||||
if (available) readinessState = { state:'ready', reason:'' };
|
||||
else {
|
||||
hydrateLegacyCache();
|
||||
readinessState = { state:'degraded', reason:'unavailable' };
|
||||
}
|
||||
resolve(Boolean(available));
|
||||
});
|
||||
});
|
||||
return readinessPromise;
|
||||
}
|
||||
|
||||
function ready() {
|
||||
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
|
||||
if (readinessState.state === 'degraded') return Promise.resolve(false);
|
||||
return readinessPromise || startReady();
|
||||
}
|
||||
|
||||
function status() {
|
||||
return { ...readinessState };
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (!transact) return Promise.resolve(true);
|
||||
migrationPromise = undefined;
|
||||
readinessPromise = undefined;
|
||||
return startReady();
|
||||
}
|
||||
function enabled() {
|
||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||
catch (_) { return false; }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (transact) {
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||
if (readinessState.state === 'degraded') {
|
||||
pendingDurableClear = true;
|
||||
recordCache = new Map();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
migrationPromise = Promise.resolve(true);
|
||||
return transact(async records => {
|
||||
await records.clear();
|
||||
recordCache = new Map();
|
||||
return true;
|
||||
}).catch(() => false);
|
||||
}
|
||||
try {
|
||||
storage.removeItem(SNAPSHOT_KEY);
|
||||
storage.removeItem(DETAILS_KEY);
|
||||
}
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function detailKey(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = Number(item?.notification_id || 0);
|
||||
return Number.isInteger(notificationId) && notificationId > 0 ? 'update:' + notificationId : '';
|
||||
}
|
||||
const kind = item?.kind === 'review' ? 'review' :
|
||||
item?.kind === 'pull' ? 'pull' : item?.kind === 'issue' ? 'issue' : '';
|
||||
const repository = String(item?.repository || '');
|
||||
const number = Number(item?.number || 0);
|
||||
return kind && repository && number > 0 ? [kind, repository, number].join(':') : '';
|
||||
}
|
||||
|
||||
function readDetails() {
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (_) {
|
||||
try { storage.removeItem(DETAILS_KEY); } catch (_error) { /* Best effort purge. */ }
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveDetail(login, item, detail) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!enabled() || !login || !key || !detail) return false;
|
||||
const conversation = detail.conversation || {};
|
||||
const data = {
|
||||
...pick(detail, DETAIL_FIELDS),
|
||||
conversation: {
|
||||
comments: (conversation.comments || []).slice(-20).map(comment => pick(comment, COMMENT_FIELDS)),
|
||||
page: Number(conversation.page || 1),
|
||||
older_page: conversation.older_page ?? null,
|
||||
total: Number(conversation.total || 0),
|
||||
},
|
||||
};
|
||||
if (item?.is_review || item?.kind === 'review') {
|
||||
data.files = (detail.files || []).slice(0, 50).map(file => ({
|
||||
...pick(file, REVIEW_FILE_FIELDS),
|
||||
diff_lines: (file?.diff_lines || []).slice(0, 400).map(line => String(line)),
|
||||
}));
|
||||
data.reviews = (detail.reviews || []).slice(-20).map(review => ({
|
||||
...pick(review, REVIEW_FIELDS),
|
||||
user: pick(review?.user, ['login']),
|
||||
}));
|
||||
}
|
||||
const record = {
|
||||
key,
|
||||
user_login: login,
|
||||
saved_at: now().toISOString(),
|
||||
data,
|
||||
};
|
||||
if (transact) {
|
||||
record.id = 'detail:' + login + ':' + key;
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
const existing = (await records.getAll()).filter(candidate =>
|
||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||
);
|
||||
await records.put(record);
|
||||
const overflow = existing.concat(record).sort((a, b) =>
|
||||
String(a.saved_at).localeCompare(String(b.saved_at))
|
||||
).slice(0, -Math.max(1, maxDetails));
|
||||
for (const candidate of overflow) await records.delete(candidate.id);
|
||||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
const records = readDetails().filter(candidate =>
|
||||
!(candidate?.key === key && candidate?.user_login === login)
|
||||
);
|
||||
records.push(record);
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(records.slice(-Math.max(1, maxDetails)))); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function loadDetail(login, item) {
|
||||
const key = detailKey(item);
|
||||
login = String(login || '').trim();
|
||||
if (!login || !key) return null;
|
||||
if (transact) {
|
||||
const id = 'detail:' + login + ':' + key;
|
||||
if (!recordCache) return ready().then(() => loadDetail(login, item));
|
||||
const record = recordCache.get(id);
|
||||
if (!record) return null;
|
||||
const savedAt = Date.parse(record.saved_at || '');
|
||||
if (!record.data || !Number.isFinite(savedAt) || now().getTime() - savedAt > maxAgeMs) {
|
||||
recordCache.delete(id);
|
||||
transact(async records => { await records.delete(id); }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
const records = readDetails();
|
||||
const currentTime = now().getTime();
|
||||
const valid = records.filter(record => {
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
return record?.key && record?.user_login && record?.data && Number.isFinite(savedAt) &&
|
||||
currentTime - savedAt <= maxAgeMs;
|
||||
});
|
||||
if (valid.length !== records.length) {
|
||||
try { storage.setItem(DETAILS_KEY, JSON.stringify(valid)); } catch (_) { /* Best effort purge. */ }
|
||||
}
|
||||
const record = valid.find(candidate => candidate.key === key && candidate.user_login === login);
|
||||
return record ? { ...record.data, saved_at: record.saved_at } : null;
|
||||
}
|
||||
|
||||
function setEnabled(value) {
|
||||
try {
|
||||
storage.setItem(ENABLED_KEY, value ? 'true' : 'false');
|
||||
if (!value) clear();
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function save(snapshot) {
|
||||
if (!enabled() || !snapshot?.user?.login) return false;
|
||||
const record = {
|
||||
id: 'snapshot',
|
||||
version: VERSION,
|
||||
user_login: String(snapshot.user.login),
|
||||
saved_at: now().toISOString(),
|
||||
data: {
|
||||
user: pick(snapshot.user, ['login', 'full_name']),
|
||||
issues: (snapshot.issues || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
pull_requests: (snapshot.pull_requests || []).map(item => pick(item, ITEM_FIELDS)),
|
||||
notifications: (snapshot.notifications || []).map(item => pick(item, NOTIFICATION_FIELDS)),
|
||||
work_pagination: snapshot.work_pagination || {},
|
||||
notification_pagination: snapshot.notification_pagination || {},
|
||||
},
|
||||
};
|
||||
if (transact) {
|
||||
return ready().then(available => available ? transact(async records => {
|
||||
await records.put(record);
|
||||
recordCache.set(record.id, record);
|
||||
return true;
|
||||
}) : false).catch(() => false);
|
||||
}
|
||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||
catch (_) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function load(expectedLogin) {
|
||||
if (transact) {
|
||||
if (!recordCache) return ready().then(() => load(expectedLogin));
|
||||
const record = recordCache.get('snapshot');
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
if (record) {
|
||||
recordCache.delete('snapshot');
|
||||
transact(async records => { await records.delete('snapshot'); }).catch(() => {});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
let record;
|
||||
try { record = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null'); }
|
||||
catch (_) { clear(); return null; }
|
||||
const savedAt = Date.parse(record?.saved_at || '');
|
||||
const invalid = record?.version !== VERSION || !record?.user_login || !record?.data ||
|
||||
!Number.isFinite(savedAt);
|
||||
const expired = Number.isFinite(savedAt) && now().getTime() - savedAt > maxAgeMs;
|
||||
if (invalid || expired) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
if (expectedLogin && record.user_login !== expectedLogin) return null;
|
||||
return { ...record.data, saved_at: record.saved_at };
|
||||
}
|
||||
|
||||
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
|
||||
}
|
||||
|
||||
return createOfflineWorkStore;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
function createOutboxCoordinator({
|
||||
storage,
|
||||
locks = globalThis.navigator?.locks,
|
||||
channelFactory = typeof globalThis.BroadcastChannel === 'function' ?
|
||||
() => new globalThis.BroadcastChannel('stackchain-outbox-v1') : null,
|
||||
addStorageListener = globalThis.addEventListener?.bind(globalThis),
|
||||
removeStorageListener = globalThis.removeEventListener?.bind(globalThis),
|
||||
tabId = globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
leaseMs = 15000,
|
||||
} = {}) {
|
||||
const changeKey = 'stackchain.outbox-change.v1';
|
||||
const subscribers = new Set();
|
||||
const channel = channelFactory ? channelFactory() : null;
|
||||
let sequence = 0;
|
||||
|
||||
function validChange(value) {
|
||||
return value && ['issue', 'authored', 'today', 'later'].includes(value.queue) ? value : null;
|
||||
}
|
||||
|
||||
function publish(change) {
|
||||
subscribers.forEach(listener => listener(change));
|
||||
}
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = event => {
|
||||
const change = validChange(event?.data);
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
};
|
||||
}
|
||||
|
||||
const onStorage = event => {
|
||||
if (event?.key !== changeKey || !event.newValue) return;
|
||||
try {
|
||||
const change = validChange(JSON.parse(event.newValue));
|
||||
if (change && change.tabId !== tabId) publish(change);
|
||||
} catch (_error) { /* Ignore malformed cross-tab signals. */ }
|
||||
};
|
||||
if (addStorageListener) addStorageListener('storage', onStorage);
|
||||
|
||||
function notify(queue) {
|
||||
const change = { queue, tabId, changedAt: Number(now()), sequence: ++sequence };
|
||||
if (!validChange(change)) return;
|
||||
try { storage?.setItem(changeKey, JSON.stringify(change)); } catch (_error) { /* Broadcast may still work. */ }
|
||||
try { channel?.postMessage(change); } catch (_error) { /* Storage events remain available. */ }
|
||||
}
|
||||
|
||||
function skipped() {
|
||||
return { confirmed: [], remaining: [], blocked: 0, lease_skipped: true };
|
||||
}
|
||||
|
||||
async function withFallbackLease(queue, work) {
|
||||
const key = 'stackchain.outbox-lease.' + queue + '.v1';
|
||||
const token = tabId + ':' + (++sequence);
|
||||
const timestamp = Number(now());
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token && Number(current.expiresAt) > timestamp) return skipped();
|
||||
storage?.setItem(key, JSON.stringify({ token, expiresAt: timestamp + leaseMs }));
|
||||
const claimed = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (claimed?.token !== token) return skipped();
|
||||
} catch (_error) {
|
||||
return work();
|
||||
}
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
try {
|
||||
const current = JSON.parse(storage?.getItem(key) || 'null');
|
||||
if (current?.token === token) storage?.removeItem(key);
|
||||
} catch (_error) { /* An expired lease will be reclaimed. */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function runExclusive(queue, work) {
|
||||
if (locks?.request) {
|
||||
return locks.request('stackchain-outbox-' + queue + '-v1', { mode: 'exclusive', ifAvailable: true },
|
||||
lock => lock ? work() : skipped());
|
||||
}
|
||||
return withFallbackLease(queue, work);
|
||||
}
|
||||
|
||||
function subscribe(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
}
|
||||
|
||||
function close() {
|
||||
subscribers.clear();
|
||||
try { channel?.close(); } catch (_error) { /* No-op. */ }
|
||||
if (removeStorageListener) removeStorageListener('storage', onStorage);
|
||||
}
|
||||
|
||||
return { runExclusive, notify, subscribe, close };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createOutboxCoordinator;
|
||||
@@ -0,0 +1,213 @@
|
||||
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
|
||||
let available = [];
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let loadRequest = null;
|
||||
let loadGeneration = 0;
|
||||
let query = '';
|
||||
let filters = { repositories: [], labels: [] };
|
||||
let facetView;
|
||||
let claimRequest = null;
|
||||
const previewed = new Set();
|
||||
let selecting = false;
|
||||
const selected = new Map();
|
||||
|
||||
function itemKey(item) {
|
||||
return String(item?.repository || '') + '#' + String(item?.number || '');
|
||||
}
|
||||
|
||||
function emitSelection() {
|
||||
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
onSelection(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function apply(result, append) {
|
||||
const incoming = Array.isArray(result?.items) ? result.items : [];
|
||||
if (append) {
|
||||
const byId = new Map(available.map(item => [item.id, item]));
|
||||
incoming.forEach(item => {
|
||||
if (item && !byId.has(item.id)) byId.set(item.id, { ...item });
|
||||
});
|
||||
available = Array.from(byId.values());
|
||||
} else {
|
||||
available = incoming.map(item => ({ ...item }));
|
||||
}
|
||||
const availableKeys = new Set(available.map(itemKey));
|
||||
previewed.forEach(key => {
|
||||
if (!availableKeys.has(key)) previewed.delete(key);
|
||||
});
|
||||
pagination = {
|
||||
page: Number(result?.page) || 1,
|
||||
total: Number(result?.total) || available.length,
|
||||
has_more: result?.has_more === true,
|
||||
};
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
if (result?.facets) onFacets({
|
||||
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
|
||||
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
|
||||
});
|
||||
if (result?.facets && typeof document !== 'undefined') {
|
||||
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
|
||||
String(value).replace(/[&<>"']/g, character => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character])),
|
||||
next => { filters = next; loadPage(1, false); });
|
||||
facetView(result.facets);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPage(page, append, requestedQuery = query) {
|
||||
if (loadRequest && requestedQuery === query && append) return loadRequest;
|
||||
const generation = ++loadGeneration;
|
||||
const params = new URLSearchParams();
|
||||
params.set('facets', 'true');
|
||||
if (requestedQuery) params.set('q', requestedQuery);
|
||||
filters.repositories.forEach(value => params.append('repository', value));
|
||||
filters.labels.forEach(value => params.append('label', value));
|
||||
const queryPart = params.toString() ? '&' + params.toString() : '';
|
||||
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(result => {
|
||||
if (generation !== loadGeneration) return result;
|
||||
apply(result, append);
|
||||
if (result?.refresh_failed === true) {
|
||||
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
|
||||
} else if (result?.revalidating === true) {
|
||||
onStatus('Showing saved available work while the catalog refreshes…');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { if (loadRequest === request) loadRequest = null; });
|
||||
loadRequest = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
return {
|
||||
reset(result) {
|
||||
apply(result, false);
|
||||
},
|
||||
load() {
|
||||
return loadPage(1, false);
|
||||
},
|
||||
loadMore() {
|
||||
if (!pagination.has_more) return Promise.resolve(false);
|
||||
return loadPage(pagination.page + 1, true);
|
||||
},
|
||||
search(value) {
|
||||
query = String(value || '').trim();
|
||||
return loadPage(1, false, query);
|
||||
},
|
||||
query() {
|
||||
return query;
|
||||
},
|
||||
setFilters(next) {
|
||||
filters = {
|
||||
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
|
||||
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
|
||||
};
|
||||
return loadPage(1, false);
|
||||
},
|
||||
filters() {
|
||||
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
|
||||
},
|
||||
|
||||
items() {
|
||||
return available.slice();
|
||||
},
|
||||
startSelection() {
|
||||
selecting = true;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
cancelSelection() {
|
||||
selecting = false;
|
||||
selected.clear();
|
||||
return emitSelection();
|
||||
},
|
||||
toggleSelection(item) {
|
||||
if (!selecting || !item) return emitSelection();
|
||||
const key = itemKey(item);
|
||||
if (selected.has(key)) selected.delete(key);
|
||||
else selected.set(key, item);
|
||||
return emitSelection();
|
||||
},
|
||||
fillSelection(limit) {
|
||||
limit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (!limit) return { selected: selected.size, added: 0, limit };
|
||||
selecting = true;
|
||||
const before = selected.size;
|
||||
available.some(item => {
|
||||
if (selected.size >= limit) return true;
|
||||
selected.set(itemKey(item), item);
|
||||
return false;
|
||||
});
|
||||
emitSelection();
|
||||
return { selected: selected.size, added: selected.size - before, limit };
|
||||
},
|
||||
isSelected(item) {
|
||||
return selected.has(itemKey(item));
|
||||
},
|
||||
selectedItems() {
|
||||
return Array.from(selected.values());
|
||||
},
|
||||
selection() {
|
||||
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
|
||||
},
|
||||
togglePreview(item) {
|
||||
const key = itemKey(item);
|
||||
if (previewed.has(key)) previewed.delete(key);
|
||||
else previewed.add(key);
|
||||
return previewed.has(key);
|
||||
},
|
||||
isPreviewed(item) {
|
||||
return previewed.has(itemKey(item));
|
||||
},
|
||||
claim(item) {
|
||||
if (claimRequest) return claimRequest;
|
||||
const key = item.repository + '#' + item.number;
|
||||
onStatus('Assigning ' + key + '…');
|
||||
const path = 'api/v1/repos/' + item.repository.split('/').map(encodeURIComponent).join('/') +
|
||||
'/issues/' + encodeURIComponent(item.number) + '/claim';
|
||||
claimRequest = fetchJson(path, {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(confirmed => {
|
||||
if (!Array.isArray(confirmed?.assignees) || !confirmed.assignees.length) {
|
||||
throw new Error('Issue assignment was not confirmed.');
|
||||
}
|
||||
available = available.filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number
|
||||
);
|
||||
pagination.total = Math.max(available.length, pagination.total - 1);
|
||||
pagination.has_more = available.length < pagination.total;
|
||||
previewed.delete(itemKey(item));
|
||||
selected.delete(itemKey(item));
|
||||
emitSelection();
|
||||
onItems(available.slice());
|
||||
onPagination({ ...pagination });
|
||||
onStatus('Assigned ' + key + ' to you.');
|
||||
return confirmed;
|
||||
}).finally(() => { claimRequest = null; });
|
||||
return claimRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function attachFindWorkFacets(root, escapeHtml, onChange) {
|
||||
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
|
||||
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
|
||||
root.querySelector('#clear-find-work-filters').onclick = () => {
|
||||
root.querySelectorAll('input').forEach(input => { input.checked = false; });
|
||||
onChange({ repositories:[], labels:[] });
|
||||
};
|
||||
return facets => {
|
||||
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
|
||||
const render = (id, choices, selected, name) => {
|
||||
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
|
||||
};
|
||||
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
|
||||
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
|
||||
const count = active.repositories.length + active.labels.length;
|
||||
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
|
||||
@@ -0,0 +1,63 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createPlanTodayPreview = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createPlanTodayPreview({
|
||||
planner,
|
||||
identity,
|
||||
getScroll = () => 0,
|
||||
setScroll = () => {},
|
||||
onOpen = () => {},
|
||||
onClose = () => {},
|
||||
}) {
|
||||
let current = null;
|
||||
|
||||
function open(item, trigger = null) {
|
||||
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
|
||||
current = { item, trigger, scroll:Number(getScroll()) || 0 };
|
||||
onOpen(item, trigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setDependencies({ available, dependencies } = {}) {
|
||||
if (!current) return false;
|
||||
current.dependencies_available = available === true;
|
||||
current.dependencies = Array.isArray(dependencies) ? dependencies.filter(item => item?.state === 'open') : [];
|
||||
current.requires_override = !current.dependencies_available || current.dependencies.length > 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false, override = false } = {}) {
|
||||
if (!current) return 'closed';
|
||||
if (add && current.requires_override && !override) {
|
||||
return current.dependencies_available ? 'blocked' : 'dependencies-unavailable';
|
||||
}
|
||||
const { item, trigger, scroll } = current;
|
||||
let result = 'returned';
|
||||
if (add) {
|
||||
const id = identity(item);
|
||||
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
|
||||
}
|
||||
current = null;
|
||||
setScroll(scroll);
|
||||
onClose(item, trigger);
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
if (!current) return { open:false };
|
||||
const state = { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll };
|
||||
if (Object.prototype.hasOwnProperty.call(current, 'dependencies_available')) {
|
||||
state.dependencies_available = current.dependencies_available;
|
||||
state.dependencies = current.dependencies;
|
||||
state.requires_override = current.requires_override;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
return { open, close, setDependencies, snapshot };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
|
||||
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
|
||||
let generation = 0;
|
||||
|
||||
async function run(items) {
|
||||
const runGeneration = ++generation;
|
||||
const candidates = Array.from(items || []);
|
||||
const results = new Array(candidates.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < candidates.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
const item = candidates[index];
|
||||
let result;
|
||||
try {
|
||||
result = await inspect(item);
|
||||
} catch (_error) {
|
||||
result = { status:'unverified' };
|
||||
}
|
||||
results[index] = [identity(item), result];
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from(
|
||||
{ length:Math.min(workerCount, candidates.length) },
|
||||
() => worker(),
|
||||
));
|
||||
if (runGeneration !== generation) return null;
|
||||
return Object.fromEntries(results);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
return { run, cancel };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
|
||||
@@ -0,0 +1,199 @@
|
||||
function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||
let openState = false;
|
||||
let draftIds = [];
|
||||
let itemsById = new Map();
|
||||
let capacityMinutes = null;
|
||||
let estimates = {};
|
||||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
let buildState = null;
|
||||
let buildReadiness = null;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
for (const item of items || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && !unique.has(id)) unique.set(id, item);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
|
||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||
draftIds = [];
|
||||
for (const item of selectedItems || []) {
|
||||
const id = identity?.(item);
|
||||
if (id && itemsById.has(id) && !draftIds.includes(id) && draftIds.length < limit) draftIds.push(id);
|
||||
}
|
||||
capacityAware = Boolean(planning && ('capacity_minutes' in planning || 'estimates' in planning));
|
||||
capacityMinutes = Number.isInteger(planning?.capacity_minutes) && planning.capacity_minutes > 0
|
||||
? planning.capacity_minutes : null;
|
||||
estimates = {};
|
||||
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
||||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||
}
|
||||
if (Object.keys(recommendations).length) capacityAware = true;
|
||||
openState = true;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!openState) return 'closed';
|
||||
const id = identity?.(item);
|
||||
if (!id) return 'unavailable';
|
||||
itemsById.set(id, item);
|
||||
const index = draftIds.indexOf(id);
|
||||
if (index >= 0) {
|
||||
draftIds.splice(index, 1);
|
||||
return 'removed';
|
||||
}
|
||||
if (draftIds.length >= limit) return 'full';
|
||||
draftIds.push(id);
|
||||
return 'added';
|
||||
}
|
||||
|
||||
function move(id, direction) {
|
||||
const index = draftIds.indexOf(id);
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (!openState || index < 0 || target < 0 || target >= draftIds.length) return false;
|
||||
[draftIds[index], draftIds[target]] = [draftIds[target], draftIds[index]];
|
||||
return true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
openState = false;
|
||||
draftIds = [];
|
||||
itemsById = new Map();
|
||||
capacityMinutes = null;
|
||||
estimates = {};
|
||||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
buildState = null;
|
||||
buildReadiness = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setCapacity(minutes) {
|
||||
capacityAware = true;
|
||||
capacityMinutes = Number.isInteger(minutes) && minutes > 0 ? minutes : null;
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setEstimate(id, minutes) {
|
||||
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
|
||||
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
|
||||
capacityAware = true;
|
||||
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
|
||||
estimates[id] = minutes;
|
||||
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyRecommendation(id) {
|
||||
const minutes = recommendations[id];
|
||||
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
|
||||
estimates[id] = minutes;
|
||||
delete recommendations[id];
|
||||
capacityAware = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecommendation(readiness = {}) {
|
||||
if (!openState) return null;
|
||||
buildReadiness = { ...readiness };
|
||||
const selected = [];
|
||||
const reasons = {};
|
||||
const skipped = [];
|
||||
const needsEstimate = [];
|
||||
let used = 0;
|
||||
for (const [id, item] of itemsById) {
|
||||
if (selected.length >= limit) break;
|
||||
const check = readiness[id] || { status:'unverified' };
|
||||
if (check.status !== 'ready') {
|
||||
skipped.push({
|
||||
id,
|
||||
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
||||
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const minutes = estimates[id];
|
||||
if (!Number.isInteger(minutes)) {
|
||||
needsEstimate.push(id);
|
||||
continue;
|
||||
}
|
||||
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
||||
selected.push(id);
|
||||
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
||||
used += minutes;
|
||||
}
|
||||
draftIds = selected;
|
||||
capacityAware = true;
|
||||
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
||||
return buildState;
|
||||
}
|
||||
|
||||
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
const state = snapshot();
|
||||
if (state.over_capacity && !confirmOverCapacity) return 'confirm-over-capacity';
|
||||
const payload = capacityAware ? {
|
||||
ids,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: Object.fromEntries(ids.filter(id => estimates[id]).map(id => [id, estimates[id]])),
|
||||
} : ids;
|
||||
if (save?.(payload) === false) return 'unavailable';
|
||||
const first = ids.length ? itemsById.get(ids[0]) : null;
|
||||
close();
|
||||
if (startAfterSave && first) start?.(first);
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const basic = { open: openState, ids: [...draftIds], count: draftIds.length, limit };
|
||||
if (!capacityAware) return basic;
|
||||
const selectedEstimates = Object.fromEntries(draftIds.filter(id => estimates[id]).map(id => [id, estimates[id]]));
|
||||
const plannedMinutes = Object.values(selectedEstimates).reduce((total, value) => total + value, 0);
|
||||
const remainingMinutes = capacityMinutes === null ? null : capacityMinutes - plannedMinutes;
|
||||
return {
|
||||
...basic,
|
||||
capacity_minutes: capacityMinutes,
|
||||
estimates: selectedEstimates,
|
||||
...(recommendationAware ? { recommendations:Object.fromEntries(
|
||||
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
||||
) } : {}),
|
||||
planned_minutes: plannedMinutes,
|
||||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||
...(buildState ? { build:buildState } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function item(id) {
|
||||
return itemsById.get(id) || null;
|
||||
}
|
||||
|
||||
function candidates() {
|
||||
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
|
||||
}
|
||||
|
||||
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||
@@ -0,0 +1,63 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.stackchainPrivateDeviceData = factory({
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
MessageChannel: root.MessageChannel,
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : this, function createPrivateDeviceDataPurger({
|
||||
localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel,
|
||||
}) {
|
||||
function removeOwnedStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
return async function clearPrivateDeviceData() {
|
||||
removeOwnedStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeOwnedStorage(sessionStorage);
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
function createProtectToday() {
|
||||
function propose({ agenda = [], today = [], identity, limit = 5 }) {
|
||||
const key = item => String(identity?.(item) || '');
|
||||
const urgent = [];
|
||||
const seen = new Set();
|
||||
for (const item of [...agenda, ...today]) {
|
||||
const id = key(item);
|
||||
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
|
||||
['Overdue', 'Today'].includes(item.agenda_group);
|
||||
if (!eligible || !id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
urgent.push(item);
|
||||
}
|
||||
urgent.sort((left, right) =>
|
||||
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
|
||||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
|
||||
Number(left.number || 0) - Number(right.number || 0)
|
||||
);
|
||||
if (!urgent.length) return {
|
||||
selected:[], protected:[], displaced:[],
|
||||
summary:'No overdue or due-today work needs protection.',
|
||||
};
|
||||
const existing = [];
|
||||
for (const item of today) {
|
||||
const id = key(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
existing.push(item);
|
||||
}
|
||||
const combined = [...urgent, ...existing];
|
||||
const selected = combined.slice(0, limit);
|
||||
const selectedIds = new Set(selected.map(key));
|
||||
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
|
||||
return {
|
||||
selected,
|
||||
protected:urgent,
|
||||
displaced,
|
||||
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
|
||||
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
|
||||
};
|
||||
}
|
||||
return { propose };
|
||||
}
|
||||
|
||||
const protectToday = createProtectToday();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
|
||||
@@ -0,0 +1,326 @@
|
||||
function mergeEligibility(detail, reviewState) {
|
||||
if (!detail || detail.state !== 'open' || detail.merged) {
|
||||
return { allowed: false, reason: 'Pull request is not open' };
|
||||
}
|
||||
if (detail.draft) return { allowed: false, reason: 'Draft pull requests cannot be merged' };
|
||||
if (!detail.mergeable) return { allowed: false, reason: 'Resolve conflicts before merging' };
|
||||
if (detail.ci_state !== 'success') {
|
||||
const blockers = (detail.checks || []).filter(check =>
|
||||
['failure', 'error', 'pending'].includes(check.state)
|
||||
).map(check => check.name).filter(Boolean).slice(0, 3);
|
||||
return { allowed: false, reason: blockers.length ?
|
||||
'CI blocked by ' + blockers.join(', ') : 'CI must succeed before merging' };
|
||||
}
|
||||
if (!detail.head_sha) return { allowed: false, reason: 'Current head is unavailable' };
|
||||
if (reviewState && !reviewState.complete) {
|
||||
return { allowed: false, reason: 'Review every changed file before merging' };
|
||||
}
|
||||
return { allowed: true, reason: 'Ready to merge' };
|
||||
}
|
||||
|
||||
function renderFile(file, index, reviewed, escapeHtml) {
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
const panelId = 'pull-diff-' + index;
|
||||
const lines = (file.diff_lines || []).map(line => {
|
||||
const text = String(line);
|
||||
const kind = text.startsWith('@@') ? 'hunk' : text.startsWith('+') ? 'added' :
|
||||
text.startsWith('-') ? 'removed' : 'context';
|
||||
return '<span class="pull-diff-line ' + kind + '">' + escapeHtml(text) + '</span>';
|
||||
}).join('');
|
||||
const preview = file.diff_available
|
||||
? '<pre class="pull-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="pull-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') + '</pre>'
|
||||
: '<div class="pull-diff-empty" id="' + panelId + '" hidden>' +
|
||||
(file.diff_binary ? 'Binary file · preview unavailable.' : 'Diff preview unavailable.') +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
return '<article class="pull-file" data-pull-filename="' + filename + '">' +
|
||||
'<button type="button" class="pull-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' + escapeHtml(file.status || 'changed') +
|
||||
' · +' + Number(file.additions || 0) + ' / −' + Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<button type="button" class="pull-review-file" data-pull-review-file="' + filename +
|
||||
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
|
||||
}
|
||||
|
||||
function focusNextUnreviewed(doc, detail, state, controller) {
|
||||
if (!detail || !state) return;
|
||||
const filename = controller.nextUnreviewed(detail, state);
|
||||
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
|
||||
.find(file => file.dataset.pullFilename === filename);
|
||||
const toggle = article?.querySelector('.pull-file-toggle');
|
||||
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
|
||||
if (!toggle || !panel) return;
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
panel.hidden = false;
|
||||
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function removeFromSnapshot(data, item) {
|
||||
return { ...data, pulls:(data.pulls || []).filter(candidate =>
|
||||
candidate.repository !== item.repository || candidate.number !== item.number) };
|
||||
}
|
||||
|
||||
function sameTarget(left, right) {
|
||||
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
|
||||
}
|
||||
|
||||
function ownershipSelectors() {
|
||||
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
|
||||
}
|
||||
|
||||
function renderHandoffCandidates(select, candidates, doc) {
|
||||
select.textContent = '';
|
||||
const placeholder = doc.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
|
||||
select.appendChild(placeholder);
|
||||
candidates.forEach(candidate => {
|
||||
const option = doc.createElement('option');
|
||||
option.value = candidate.login;
|
||||
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
|
||||
select.appendChild(option);
|
||||
});
|
||||
return candidates.length;
|
||||
}
|
||||
|
||||
function ownershipExitMessage(item, action, transitionResult) {
|
||||
return item.key + ' ' + action + (transitionResult === 'opened' ?
|
||||
'. Next work item opened.' : '. Choose the next ready Today item.');
|
||||
}
|
||||
|
||||
function resetOwnershipControls(doc, item, checkpointed) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
qs('#pull-ownership').open = false;
|
||||
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
|
||||
qs('#pull-handoff-recipient').disabled = true;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
|
||||
qs('#load-pull-handoff').disabled = false;
|
||||
qs('#release-pull').disabled = false;
|
||||
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
|
||||
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
|
||||
}
|
||||
|
||||
function bindOwnershipControls(doc, controller, getSelected, finish) {
|
||||
const qs = selector => doc.querySelector(selector);
|
||||
const load = qs('#load-pull-handoff');
|
||||
if (load.dataset.ownershipBound === 'true') return;
|
||||
load.dataset.ownershipBound = 'true';
|
||||
load.addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected) return;
|
||||
const select = qs('#pull-handoff-recipient');
|
||||
load.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
|
||||
try {
|
||||
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
|
||||
select.disabled = !count;
|
||||
qs('#confirm-pull-handoff').disabled = true;
|
||||
qs('#pull-handoff-status').textContent = count ?
|
||||
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
|
||||
if (count) select.focus();
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
|
||||
load.disabled = false;
|
||||
load.focus();
|
||||
}
|
||||
});
|
||||
qs('#pull-handoff-recipient').addEventListener('change', event => {
|
||||
qs('#confirm-pull-handoff').disabled = !event.target.value;
|
||||
});
|
||||
qs('#confirm-pull-handoff').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
const recipient = qs('#pull-handoff-recipient').value;
|
||||
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
|
||||
const button = qs('#confirm-pull-handoff');
|
||||
button.disabled = true;
|
||||
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
|
||||
try {
|
||||
await controller.handoff(selected, recipient);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' handed off to @' + recipient + '.' :
|
||||
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
|
||||
} catch (error) {
|
||||
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#release-pull').addEventListener('click', async () => {
|
||||
const selected = getSelected();
|
||||
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
|
||||
const button = qs('#release-pull');
|
||||
button.disabled = true;
|
||||
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
|
||||
try {
|
||||
await controller.release(selected);
|
||||
const transition = await finish(selected);
|
||||
qs('#my-work-action-status').textContent = transition === false ?
|
||||
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
|
||||
button.disabled = false;
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
|
||||
let commentRequest = null;
|
||||
let mergeRequest = null;
|
||||
let checkRequest = null;
|
||||
let ownershipRequest = null;
|
||||
let candidateRequest = null;
|
||||
const reviewRequests = new Map();
|
||||
const reviewCache = new Map();
|
||||
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
|
||||
.map(encodeURIComponent).join('/') + '/pulls/' + encodeURIComponent(item.number);
|
||||
const draftKey = item => 'stackchain.pull-comment.v1:' + item.repository + '#' + item.number;
|
||||
const operationKey = item => draftKey(item) + ':operation';
|
||||
const reviewKey = (item, detail) => 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
const fileNames = detail => (detail?.files || []).map(file => file.filename).filter(Boolean);
|
||||
|
||||
function reviewState(item, detail) {
|
||||
const files = fileNames(detail);
|
||||
let saved = [];
|
||||
try {
|
||||
const value = JSON.parse(storage?.getItem(reviewKey(item, detail)) || '[]');
|
||||
if (Array.isArray(value)) saved = value;
|
||||
} catch (_error) { /* Corrupt progress safely starts over. */ }
|
||||
const reviewed = files.filter(filename => saved.includes(filename));
|
||||
return { reviewed, total: files.length, complete: reviewed.length === files.length };
|
||||
}
|
||||
|
||||
return {
|
||||
load(item) {
|
||||
return fetchJson(pathFor(item) + '/detail', { headers: { Accept: 'application/json' } });
|
||||
},
|
||||
loadReview(item, headSha, { refresh = false } = {}) {
|
||||
const key = item.repository + '#' + item.number + ':' + headSha;
|
||||
if (!refresh && reviewCache.has(key)) return Promise.resolve(reviewCache.get(key));
|
||||
if (reviewRequests.has(key)) return reviewRequests.get(key);
|
||||
const request = fetchJson(pathFor(item) + '/review-data', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).then(detail => {
|
||||
const confirmedKey = item.repository + '#' + item.number + ':' + detail.head_sha;
|
||||
reviewCache.set(confirmedKey, detail);
|
||||
return detail;
|
||||
}).finally(() => reviewRequests.delete(key));
|
||||
reviewRequests.set(key, request);
|
||||
return request;
|
||||
},
|
||||
loadChecks(item) {
|
||||
if (checkRequest) return checkRequest;
|
||||
checkRequest = fetchJson(pathFor(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { checkRequest = null; });
|
||||
return checkRequest;
|
||||
},
|
||||
loadHandoffCandidates(item) {
|
||||
if (candidateRequest) return candidateRequest;
|
||||
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { candidateRequest = null; });
|
||||
return candidateRequest;
|
||||
},
|
||||
handoff(item, recipient) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient }),
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
release(item) {
|
||||
if (ownershipRequest) return ownershipRequest;
|
||||
ownershipRequest = fetchJson(pathFor(item) + '/release', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' },
|
||||
}).finally(() => { ownershipRequest = null; });
|
||||
return ownershipRequest;
|
||||
},
|
||||
conversation(item, initialPage) {
|
||||
const pager = createConversationPager({
|
||||
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
});
|
||||
pager.reset(initialPage || { comments: [], page: 1, older_page: null, total: 0 });
|
||||
return pager;
|
||||
},
|
||||
reviewState,
|
||||
toggleReviewed(item, detail, filename) {
|
||||
const state = reviewState(item, detail);
|
||||
const reviewed = new Set(state.reviewed);
|
||||
if (reviewed.has(filename)) reviewed.delete(filename);
|
||||
else if (fileNames(detail).includes(filename)) reviewed.add(filename);
|
||||
try { storage?.setItem(reviewKey(item, detail), JSON.stringify(Array.from(reviewed))); }
|
||||
catch (_error) { /* In-memory controls still work for this render. */ }
|
||||
return reviewState(item, detail);
|
||||
},
|
||||
nextUnreviewed(detail, state) {
|
||||
return fileNames(detail).find(filename => !state.reviewed.includes(filename)) || null;
|
||||
},
|
||||
loadDraft(item) {
|
||||
try { return storage?.getItem(draftKey(item)) || ''; }
|
||||
catch (_error) { return ''; }
|
||||
},
|
||||
saveDraft(item, body) {
|
||||
try {
|
||||
if ((storage?.getItem(draftKey(item)) || '') !== body) storage?.removeItem(operationKey(item));
|
||||
storage?.setItem(draftKey(item), body);
|
||||
}
|
||||
catch (_error) { /* The textarea remains the fallback. */ }
|
||||
},
|
||||
comment(item, body) {
|
||||
if (commentRequest) return commentRequest;
|
||||
this.saveDraft(item, body);
|
||||
let operationId;
|
||||
try {
|
||||
operationId = storage?.getItem(operationKey(item)) || String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey(item), operationId);
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
commentRequest = fetchJson(pathFor(item) + '/comments', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Idempotency-Key': operationId },
|
||||
body: JSON.stringify({ body }),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(draftKey(item)); }
|
||||
catch (_error) { /* The upstream comment is authoritative. */ }
|
||||
try { storage?.removeItem(operationKey(item)); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { commentRequest = null; });
|
||||
return commentRequest;
|
||||
},
|
||||
merge(item, expectedHeadSha) {
|
||||
if (mergeRequest) return mergeRequest;
|
||||
mergeRequest = fetchJson(pathFor(item) + '/merge', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expected_head_sha: expectedHeadSha }),
|
||||
}).then(result => {
|
||||
if (!result?.merged) {
|
||||
throw new Error(result?.confirmation_pending && result?.error
|
||||
? result.error
|
||||
: 'Pull request merge was not confirmed.');
|
||||
}
|
||||
return result;
|
||||
}).finally(() => { mergeRequest = null; });
|
||||
return mergeRequest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
createPullSheet.mergeEligibility = mergeEligibility;
|
||||
createPullSheet.renderFile = renderFile;
|
||||
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
|
||||
createPullSheet.removeFromSnapshot = removeFromSnapshot;
|
||||
createPullSheet.sameTarget = sameTarget;
|
||||
createPullSheet.ownershipSelectors = ownershipSelectors;
|
||||
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
|
||||
createPullSheet.ownershipExitMessage = ownershipExitMessage;
|
||||
createPullSheet.resetOwnershipControls = resetOwnershipControls;
|
||||
createPullSheet.bindOwnershipControls = bindOwnershipControls;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
|
||||
@@ -0,0 +1,159 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
function formattedHour(value) {
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
function applicationServerKey(value) {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
if (typeof atob === 'function') {
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(padded, 'base64'));
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
const registration = await serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
|
||||
await subscription?.unsubscribe?.();
|
||||
control.checked = false;
|
||||
if (deadlineControl) deadlineControl.checked = false;
|
||||
status.textContent = 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
async function ensureSubscription() {
|
||||
const permission = await notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
control.checked = false;
|
||||
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
|
||||
return null;
|
||||
}
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey(configuration.public_key),
|
||||
});
|
||||
}
|
||||
await fetchJson('api/v1/push-subscription', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify(subscription.toJSON()),
|
||||
});
|
||||
control.checked = true;
|
||||
status.textContent = 'New update notifications enabled for this device.';
|
||||
configuration.subscribed = true;
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
await ensureSubscription();
|
||||
}
|
||||
|
||||
async function change() {
|
||||
control.disabled = true;
|
||||
try {
|
||||
if (control.checked) await enable();
|
||||
else await disable();
|
||||
} catch (error) {
|
||||
control.checked = !control.checked;
|
||||
status.textContent = 'Could not change update notifications. Check your connection and try again.';
|
||||
} finally {
|
||||
control.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
|
||||
if (deadlineControl.checked && !subscription) {
|
||||
deadlineControl.checked = false;
|
||||
deadlineStatus.textContent = status.textContent;
|
||||
return false;
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
deadlineControl.checked = !deadlineControl.checked;
|
||||
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
|
||||
return false;
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableDeadline() {
|
||||
deadlineControl.checked = true;
|
||||
return changeDeadline();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (!control || !notification || !serviceWorker) return;
|
||||
control.addEventListener('change', change);
|
||||
deadlineControl?.addEventListener('change', changeDeadline);
|
||||
configuration = await fetchJson('api/v1/push-subscription');
|
||||
if (!configuration.available) {
|
||||
control.disabled = true;
|
||||
if (deadlineControl) deadlineControl.disabled = true;
|
||||
status.textContent = 'New update notifications are not available on this server.';
|
||||
return;
|
||||
}
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
|
||||
return function queueToday(issue) {
|
||||
const added = todayWork.add(issue);
|
||||
if (added !== 'added' && added !== 'exists') return added;
|
||||
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
|
||||
refresh();
|
||||
warm();
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
return 'queued';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
|
||||
+359
-9
@@ -1,4 +1,11 @@
|
||||
function createReviewController({ fetchJson }) {
|
||||
function createReviewController({
|
||||
fetchJson,
|
||||
storage,
|
||||
createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random(),
|
||||
}) {
|
||||
let pendingSubmission = null;
|
||||
let pendingChecks = null;
|
||||
|
||||
function endpoint(item) {
|
||||
const [owner, repo] = String(item.repository || '').split('/');
|
||||
if (!owner || !repo || !Number.isInteger(Number(item.number))) {
|
||||
@@ -12,7 +19,63 @@ function createReviewController({ fetchJson }) {
|
||||
return fetchJson(endpoint(item), { headers: { Accept: 'application/json' } });
|
||||
}
|
||||
|
||||
return { load };
|
||||
function loadChecks(item) {
|
||||
if (pendingChecks) return pendingChecks;
|
||||
pendingChecks = fetchJson(endpoint(item) + '/checks', {
|
||||
headers: { Accept: 'application/json' },
|
||||
}).finally(() => { pendingChecks = null; });
|
||||
return pendingChecks;
|
||||
}
|
||||
|
||||
function submit(item, payload) {
|
||||
if (pendingSubmission) return pendingSubmission;
|
||||
const operationKey = 'stackchain.review-submit.v1:' + item.repository + '#' + item.number;
|
||||
const fingerprint = JSON.stringify(payload);
|
||||
let operationId;
|
||||
try {
|
||||
const saved = JSON.parse(storage?.getItem(operationKey) || 'null');
|
||||
operationId = saved?.fingerprint === fingerprint && saved?.operationId
|
||||
? saved.operationId : String(createOperationId()).slice(0, 128);
|
||||
storage?.setItem(operationKey, JSON.stringify({ fingerprint, operationId }));
|
||||
} catch (_error) { operationId = String(createOperationId()).slice(0, 128); }
|
||||
pendingSubmission = fetchJson(endpoint(item), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json', 'Content-Type': 'application/json',
|
||||
'Idempotency-Key': operationId,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(result => {
|
||||
try { storage?.removeItem(operationKey); }
|
||||
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
|
||||
return result;
|
||||
}).finally(() => { pendingSubmission = null; });
|
||||
return pendingSubmission;
|
||||
}
|
||||
|
||||
return { load, loadChecks, submit };
|
||||
}
|
||||
|
||||
function createWrapPreference({ storage, mobile }) {
|
||||
const key = 'stackchain.review-wrap.v1';
|
||||
let explicit = false;
|
||||
let wrapped = Boolean(mobile);
|
||||
try {
|
||||
const saved = storage?.getItem(key);
|
||||
if (saved === 'true' || saved === 'false') {
|
||||
explicit = true;
|
||||
wrapped = saved === 'true';
|
||||
}
|
||||
} catch (_error) { /* keep the viewport default */ }
|
||||
|
||||
function snapshot() { return { wrapped, explicit }; }
|
||||
function setWrapped(value) {
|
||||
wrapped = Boolean(value);
|
||||
explicit = true;
|
||||
try { storage?.setItem(key, String(wrapped)); } catch (_error) { /* keep in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
return { snapshot, setWrapped, storageKey: key };
|
||||
}
|
||||
|
||||
function diffLineClass(line) {
|
||||
@@ -22,14 +85,63 @@ function diffLineClass(line) {
|
||||
return 'context';
|
||||
}
|
||||
|
||||
function parseDiffLines(lines) {
|
||||
let oldLine = null;
|
||||
let newLine = null;
|
||||
return (lines || []).map(value => {
|
||||
const text = String(value);
|
||||
const hunk = text.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunk) {
|
||||
oldLine = Number(hunk[1]);
|
||||
newLine = Number(hunk[2]);
|
||||
return { text, kind: 'hunk', commentable: false };
|
||||
}
|
||||
if (text.startsWith('\\')) return { text, kind: 'note', commentable: false };
|
||||
if (oldLine === null || newLine === null) {
|
||||
return { text, kind: diffLineClass(text), commentable: false };
|
||||
}
|
||||
if (text.startsWith('-')) {
|
||||
const row = { text, kind: 'removed', commentable: true, old_line: oldLine, old_position: oldLine };
|
||||
oldLine += 1;
|
||||
return row;
|
||||
}
|
||||
if (text.startsWith('+')) {
|
||||
const row = { text, kind: 'added', commentable: true, new_line: newLine, new_position: newLine };
|
||||
newLine += 1;
|
||||
return row;
|
||||
}
|
||||
const row = {
|
||||
text, kind: 'context', commentable: true,
|
||||
old_line: oldLine, new_line: newLine, new_position: newLine,
|
||||
};
|
||||
oldLine += 1;
|
||||
newLine += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function renderDiffFile(file, index, escapeHtml) {
|
||||
const panelId = 'review-diff-' + index;
|
||||
let preview;
|
||||
if (file.diff_available) {
|
||||
const lines = (file.diff_lines || []).map(line =>
|
||||
'<span class="review-diff-line ' + diffLineClass(String(line)) + '">' +
|
||||
escapeHtml(String(line)) + '</span>'
|
||||
).join('');
|
||||
const lines = parseDiffLines(file.diff_lines).map(row => {
|
||||
const oldNumber = row.old_line || row.old_position || '';
|
||||
const newNumber = row.new_line || row.new_position || '';
|
||||
const content = '<span class="review-line-numbers" aria-hidden="true">' +
|
||||
'<span class="review-line-number old">' + oldNumber + '</span>' +
|
||||
'<span class="review-line-number new">' + newNumber + '</span></span>' +
|
||||
'<span class="review-line-code">' + escapeHtml(row.text) + '</span>';
|
||||
if (!row.commentable) {
|
||||
return '<span class="review-diff-line ' + row.kind + '">' + content + '</span>';
|
||||
}
|
||||
const position = row.old_position
|
||||
? ' data-old-position="' + row.old_position + '"'
|
||||
: ' data-new-position="' + row.new_position + '"';
|
||||
return '<button type="button" class="review-diff-line review-inline-target ' + row.kind +
|
||||
'" data-review-filename="' + escapeHtml(file.filename || '') + '"' + position +
|
||||
' aria-label="Comment on ' + escapeHtml(file.filename || 'changed file') + ' line ' +
|
||||
(row.old_position || row.new_position) + '">' + content + '</button>';
|
||||
}).join('');
|
||||
preview = '<pre class="review-diff" id="' + panelId + '" hidden>' + lines +
|
||||
(file.diff_truncated ? '<span class="review-diff-note">Preview truncated · open in Gitea for the full diff.</span>' : '') +
|
||||
'</pre>';
|
||||
@@ -38,10 +150,14 @@ function renderDiffFile(file, index, escapeHtml) {
|
||||
preview = '<div class="review-diff-empty" id="' + panelId + '" hidden>' + message +
|
||||
(file.diff_truncated ? ' The response was truncated.' : '') + '</div>';
|
||||
}
|
||||
return '<div class="review-file"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + escapeHtml(file.filename || 'Unknown file') + '</strong><span class="small">' +
|
||||
const filename = escapeHtml(file.filename || 'Unknown file');
|
||||
return '<div class="review-file" data-review-filename="' + filename + '"><button class="review-file-toggle" aria-expanded="false" aria-controls="' + panelId + '">' +
|
||||
'<strong>' + filename + '</strong><span class="small">' +
|
||||
escapeHtml(file.status || 'changed') + ' · +' + Number(file.additions || 0) + ' / −' +
|
||||
Number(file.deletions || 0) + '</span></button>' + preview + '</div>';
|
||||
Number(file.deletions || 0) + '</span></button>' + preview +
|
||||
'<label class="small" for="review-note-' + index + '">Note for ' + filename + '</label>' +
|
||||
'<textarea class="review-note" id="review-note-' + index + '" data-review-filename="' + filename + '" placeholder="Capture feedback for this file"></textarea>' +
|
||||
'<button class="review-mark" data-review-filename="' + filename + '" aria-pressed="false">Mark reviewed</button></div>';
|
||||
}
|
||||
|
||||
function toggleDiff(button, panel) {
|
||||
@@ -50,8 +166,242 @@ function toggleDiff(button, panel) {
|
||||
panel.hidden = expanded;
|
||||
}
|
||||
|
||||
function createProgress({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-progress.v1:' + repository + '#' + number + '@' + headSha;
|
||||
const headKey = 'stackchain.review-progress.v1:' + repository + '#' + number + ':head';
|
||||
let reviewed = [];
|
||||
let newHead = false;
|
||||
try {
|
||||
const previousHead = storage.getItem(headKey);
|
||||
newHead = Boolean(previousHead && previousHead !== headSha);
|
||||
storage.setItem(headKey, headSha);
|
||||
const saved = JSON.parse(storage.getItem(key) || '[]');
|
||||
if (Array.isArray(saved)) reviewed = filenames.filter(filename => saved.includes(filename));
|
||||
} catch (error) {
|
||||
reviewed = [];
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const pending = filenames.find(filename => !reviewed.includes(filename)) || null;
|
||||
return {
|
||||
reviewed: [...reviewed],
|
||||
reviewedCount: reviewed.length,
|
||||
total: filenames.length,
|
||||
nextFilename: pending,
|
||||
...(newHead ? { newHead: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function markReviewed(filename) {
|
||||
if (filenames.includes(filename) && !reviewed.includes(filename)) {
|
||||
reviewed.push(filename);
|
||||
reviewed = filenames.filter(item => reviewed.includes(item));
|
||||
try { storage.setItem(key, JSON.stringify(reviewed)); } catch (error) { /* local progress remains usable */ }
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
reviewed = [];
|
||||
newHead = false;
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, markReviewed, clear, storageKey: key };
|
||||
}
|
||||
|
||||
function createDraft({ storage, repository, number, headSha, files }) {
|
||||
const filenames = (files || []).map(file => file && file.filename).filter(Boolean);
|
||||
const key = 'stackchain.review-draft.v1:' + repository + '#' + number + '@' + headSha;
|
||||
let draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || '{}');
|
||||
if (saved && typeof saved === 'object') {
|
||||
draft.notes = Object.fromEntries(filenames
|
||||
.filter(filename => typeof saved.notes?.[filename] === 'string' && saved.notes[filename])
|
||||
.map(filename => [filename, saved.notes[filename]]));
|
||||
if (Array.isArray(saved.comments)) {
|
||||
draft.comments = saved.comments.filter(comment =>
|
||||
comment && filenames.includes(comment.path) && typeof comment.body === 'string' &&
|
||||
Boolean(comment.body.trim()) &&
|
||||
((Number.isInteger(comment.new_position) && !comment.old_position) ||
|
||||
(Number.isInteger(comment.old_position) && !comment.new_position))
|
||||
).map(comment => ({
|
||||
path: comment.path, body: comment.body,
|
||||
...(comment.new_position ? { new_position: comment.new_position } : {}),
|
||||
...(comment.old_position ? { old_position: comment.old_position } : {}),
|
||||
}));
|
||||
}
|
||||
if (typeof saved.summary === 'string') draft.summary = saved.summary;
|
||||
if (['comment', 'approve', 'request_changes'].includes(saved.decision)) draft.decision = saved.decision;
|
||||
}
|
||||
} catch (error) { /* start with an empty in-memory draft */ }
|
||||
|
||||
function persist() {
|
||||
try { storage.setItem(key, JSON.stringify(draft)); } catch (error) { /* draft remains usable */ }
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
notes: { ...draft.notes }, comments: draft.comments.map(comment => ({ ...comment })),
|
||||
summary: draft.summary, decision: draft.decision,
|
||||
};
|
||||
}
|
||||
|
||||
function setNote(filename, note) {
|
||||
if (!filenames.includes(filename)) return snapshot();
|
||||
if (String(note)) draft.notes[filename] = String(note);
|
||||
else delete draft.notes[filename];
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function inlineKey(comment) {
|
||||
return comment.path + ':' + (comment.new_position ? 'new:' + comment.new_position : 'old:' + comment.old_position);
|
||||
}
|
||||
|
||||
function setInlineComment(anchor, body) {
|
||||
if (!anchor || !filenames.includes(anchor.path)) return snapshot();
|
||||
const position = Number.isInteger(anchor.new_position) && anchor.new_position > 0
|
||||
? { new_position: anchor.new_position }
|
||||
: Number.isInteger(anchor.old_position) && anchor.old_position > 0
|
||||
? { old_position: anchor.old_position } : null;
|
||||
const text = String(body || '').trim();
|
||||
if (!position || !text) return snapshot();
|
||||
const comment = { path: anchor.path, body: text, ...position };
|
||||
const key = inlineKey(comment);
|
||||
const index = draft.comments.findIndex(item => inlineKey(item) === key);
|
||||
if (index >= 0) draft.comments[index] = comment;
|
||||
else draft.comments.push(comment);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function removeInlineComment(anchor) {
|
||||
if (!anchor) return snapshot();
|
||||
const key = inlineKey(anchor);
|
||||
draft.comments = draft.comments.filter(comment => inlineKey(comment) !== key);
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setSummary(summary) {
|
||||
draft.summary = String(summary || '');
|
||||
persist();
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function setDecision(decision) {
|
||||
if (['comment', 'approve', 'request_changes'].includes(decision)) {
|
||||
draft.decision = decision;
|
||||
persist();
|
||||
}
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
draft = { notes: {}, comments: [], summary: '', decision: 'comment' };
|
||||
try { storage.removeItem(key); } catch (error) { /* cleared in memory */ }
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot, setNote, setInlineComment, removeInlineComment, setSummary, setDecision, clear,
|
||||
storageKey: key,
|
||||
};
|
||||
}
|
||||
|
||||
function formatFeedback(draft, files) {
|
||||
const decisions = {
|
||||
comment: 'Comment',
|
||||
approve: 'Approve',
|
||||
request_changes: 'Request changes',
|
||||
};
|
||||
const sections = [
|
||||
'## Intended decision\n' + (decisions[draft.decision] || decisions.comment),
|
||||
];
|
||||
if (String(draft.summary || '').trim()) {
|
||||
sections.push('## Summary\n' + String(draft.summary).trim());
|
||||
}
|
||||
const fileNotes = (files || []).flatMap(file => {
|
||||
const filename = file && file.filename;
|
||||
const note = filename && String(draft.notes?.[filename] || '').trim();
|
||||
if (!note) return [];
|
||||
return ['### `' + String(filename).replaceAll('`', '\\`') + '`\n' + note];
|
||||
});
|
||||
if (fileNotes.length) sections.push('## File notes\n' + fileNotes.join('\n\n'));
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
async function copyAndContinue({ text, url, copy, open, fallback }) {
|
||||
const destination = open();
|
||||
try {
|
||||
await copy(text);
|
||||
} catch (error) {
|
||||
destination?.close?.();
|
||||
fallback(text);
|
||||
return { copied: false, opened: false };
|
||||
}
|
||||
if (!destination) {
|
||||
fallback(text);
|
||||
return { copied: true, opened: false };
|
||||
}
|
||||
destination.location.href = url;
|
||||
return { copied: true, opened: true };
|
||||
}
|
||||
|
||||
function prepareMergeContinuation({ storage, item, headSha, reviewed, decision }) {
|
||||
const reasons = Array.isArray(item?.work_reasons) ? item.work_reasons : [];
|
||||
if (decision !== 'approve' || !reasons.includes('assigned_to_me') || !headSha) return false;
|
||||
const filenames = Array.isArray(reviewed)
|
||||
? reviewed.filter(filename => typeof filename === 'string' && filename)
|
||||
: [];
|
||||
const key = 'stackchain.pull-review.v1:' + item.repository + '#' + item.number + ':' + headSha;
|
||||
try { storage?.setItem(key, JSON.stringify(filenames)); }
|
||||
catch (_error) { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderChecks(checks, escapeHtml, { offline = false } = {}) {
|
||||
const rank = { error: 0, failure: 0, pending: 1, warning: 2, unknown: 2, success: 3 };
|
||||
const items = (Array.isArray(checks) ? checks : []).filter(check =>
|
||||
check && typeof check.name === 'string' && check.name
|
||||
).map((check, index) => ({ ...check, index })).sort((left, right) =>
|
||||
(rank[left.state] ?? 2) - (rank[right.state] ?? 2) || left.index - right.index
|
||||
);
|
||||
const failed = items.filter(check => ['failure', 'error'].includes(check.state)).length;
|
||||
const pending = items.filter(check => check.state === 'pending').length;
|
||||
const passed = items.filter(check => check.state === 'success').length;
|
||||
const other = items.length - failed - pending - passed;
|
||||
const parts = [
|
||||
failed ? failed + ' failed' : '', pending ? pending + ' pending' : '',
|
||||
passed ? passed + ' passed' : '', other ? other + ' other' : '',
|
||||
].filter(Boolean);
|
||||
const summary = (offline ? 'Last known · ' : '') + (parts.join(' · ') || 'No checks reported');
|
||||
const html = items.map(check => {
|
||||
const url = typeof check.url === 'string' ? check.url : '';
|
||||
const link = url ? '<a class="ci-check-link" href="' + escapeHtml(url) +
|
||||
'" target="_blank" rel="noopener noreferrer">Open job</a>' : '';
|
||||
return '<article class="ci-check ci-check-' + escapeHtml(check.state || 'unknown') + '">' +
|
||||
'<div class="ci-check-copy"><strong>' + escapeHtml(check.name) + '</strong>' +
|
||||
'<span class="small">' + escapeHtml(check.state || 'unknown') +
|
||||
(check.description ? ' · ' + escapeHtml(check.description) : '') + '</span></div>' + link + '</article>';
|
||||
}).join('');
|
||||
return { summary, html, expanded: failed > 0 };
|
||||
}
|
||||
|
||||
createReviewController.renderDiffFile = renderDiffFile;
|
||||
createReviewController.createWrapPreference = createWrapPreference;
|
||||
createReviewController.parseDiffLines = parseDiffLines;
|
||||
createReviewController.toggleDiff = toggleDiff;
|
||||
createReviewController.createProgress = createProgress;
|
||||
createReviewController.createDraft = createDraft;
|
||||
createReviewController.formatFeedback = formatFeedback;
|
||||
createReviewController.copyAndContinue = copyAndContinue;
|
||||
createReviewController.prepareMergeContinuation = prepareMergeContinuation;
|
||||
createReviewController.renderChecks = renderChecks;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = createReviewController;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
(function (root, factory) {
|
||||
const createSearchPreview = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = createSearchPreview;
|
||||
if (root) root.createSearchPreview = createSearchPreview;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
return function createSearchPreview({ fetchJson, mutate, onState }) {
|
||||
let generation = 0;
|
||||
let current = null;
|
||||
let mutationRequest = null;
|
||||
|
||||
function run(action, pending, success, detail) {
|
||||
if (mutationRequest) return mutationRequest;
|
||||
onState({ status: pending, item: current, detail });
|
||||
mutationRequest = mutate(detail, action).then(result => {
|
||||
onState({ status: success, item: current, detail, result });
|
||||
return result;
|
||||
}).catch(error => {
|
||||
onState({ status: 'ready', item: current, detail, error });
|
||||
throw error;
|
||||
}).finally(() => { mutationRequest = null; });
|
||||
return mutationRequest;
|
||||
}
|
||||
|
||||
return {
|
||||
open(item) {
|
||||
generation += 1;
|
||||
const requestGeneration = generation;
|
||||
current = { ...item };
|
||||
onState({ status: 'loading', item: current });
|
||||
return fetchJson(current).then(detail => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'ready', item: current, detail });
|
||||
}
|
||||
return detail;
|
||||
}).catch(error => {
|
||||
if (requestGeneration === generation) {
|
||||
onState({ status: 'error', item: current, error });
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
generation += 1;
|
||||
current = null;
|
||||
onState({ status: 'closed' });
|
||||
},
|
||||
claim(detail) {
|
||||
return run('claim', 'claiming', 'claimed', detail);
|
||||
},
|
||||
reopen(detail) {
|
||||
return run('reopen', 'reopening', 'reopened', detail);
|
||||
},
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
passkey_counter_anomaly: 'Passkey counter anomaly',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.kind === 'passkey_counter_anomaly'
|
||||
? 'Remove and re-enroll this passkey if the alert repeats'
|
||||
: null,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v100';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const SHELL = [
|
||||
BASE,
|
||||
BASE + 'manifest.webmanifest',
|
||||
BASE + 'static/dashboard.css',
|
||||
BASE + 'static/dashboard.js',
|
||||
BASE + 'static/icons/stackchain-192.png',
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
BASE + 'static/offline-issue-close.js',
|
||||
BASE + 'static/offline-issue-blocker.js',
|
||||
BASE + 'static/notification-read-outbox.js',
|
||||
BASE + 'static/offline-work.js',
|
||||
BASE + 'static/offline-today.js',
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/agenda-replan.js',
|
||||
BASE + 'static/protect-today.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
BASE + 'static/today-completion.js',
|
||||
BASE + 'static/today-readiness.js',
|
||||
BASE + 'static/comment-next.js',
|
||||
BASE + 'static/update-reply-read-next.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-readiness.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/batch-find-work.js',
|
||||
BASE + 'static/conversation.js',
|
||||
BASE + 'static/comment-actions.js',
|
||||
BASE + 'static/issue-attachment.js',
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
BASE + 'static/mobile-composer-viewport.js',
|
||||
BASE + 'static/mention-composer.js',
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
const deadline = new Promise((resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Dashboard navigation timed out.'));
|
||||
}, NAVIGATION_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
fetch(request, { signal: controller.signal }),
|
||||
deadline,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionCsrf(signal) {
|
||||
const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return '';
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return typeof payload.csrf_token === 'string' ? payload.csrf_token : '';
|
||||
}
|
||||
|
||||
let batchedCsrf = null;
|
||||
let batchedCsrfController = null;
|
||||
async function withSessionCsrf(work) {
|
||||
batchedCsrfController = new AbortController();
|
||||
batchedCsrf = sessionCsrf(batchedCsrfController.signal);
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
batchedCsrfController.abort();
|
||||
batchedCsrf = null;
|
||||
batchedCsrfController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
|
||||
}
|
||||
|
||||
async function notifyIdleSession() {
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' }));
|
||||
}
|
||||
|
||||
async function storeOfflineLease(expiresAt, idleExpiresAt) {
|
||||
if (
|
||||
!Number.isInteger(expiresAt) || expiresAt <= 0
|
||||
|| !Number.isInteger(idleExpiresAt) || idleExpiresAt <= 0
|
||||
) return;
|
||||
const cache = await caches.open(CACHE);
|
||||
await cache.put(OFFLINE_LEASE_URL, new Response(
|
||||
JSON.stringify({ expires_at: expiresAt, idle_expires_at: idleExpiresAt }),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
|
||||
));
|
||||
}
|
||||
|
||||
async function offlineLeaseState(cache) {
|
||||
const response = await cache.match(OFFLINE_LEASE_URL);
|
||||
const payload = await response?.json?.().catch(() => ({})) || {};
|
||||
const current = Math.floor(Date.now() / 1000);
|
||||
if (!Number.isInteger(payload.expires_at) || payload.expires_at <= current) return 'expired';
|
||||
if (!Number.isInteger(payload.idle_expires_at) || payload.idle_expires_at <= current) return 'idle';
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
);
|
||||
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
|
||||
return new Response(
|
||||
'Your Stackchain session expired. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function idleOfflineResponse() {
|
||||
await notifyIdleSession();
|
||||
return new Response(
|
||||
'Your Stackchain session is locked. Reconnect and sign in.',
|
||||
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedShellWithValidLease(cache) {
|
||||
const state = await offlineLeaseState(cache);
|
||||
if (state === 'expired') return expiredOfflineResponse();
|
||||
if (state === 'idle') return idleOfflineResponse();
|
||||
return cache.match(BASE);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const requestOptions = { ...options };
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const cancelBatchedCsrf = () => batchedCsrfController?.abort();
|
||||
if (batchedCsrf && options.signal) {
|
||||
if (options.signal.aborted) cancelBatchedCsrf();
|
||||
else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true });
|
||||
}
|
||||
let csrf;
|
||||
try {
|
||||
csrf = await (batchedCsrf || sessionCsrf(options.signal));
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', cancelBatchedCsrf);
|
||||
}
|
||||
if (options.signal?.aborted) throw new Error('Background request aborted.');
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetch(new URL(url, self.location.origin), requestOptions);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const code = payload.code || payload.detail?.code;
|
||||
if (response.status === 401 && code === 'session_revoked') {
|
||||
await purgeRevokedSessionData();
|
||||
}
|
||||
if (response.status === 401 && code === 'session_idle') {
|
||||
await notifyIdleSession();
|
||||
}
|
||||
const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.');
|
||||
error.status = response.status;
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const issueSync = self.__issueSync || createBackgroundIssueSync({
|
||||
store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf,
|
||||
});
|
||||
|
||||
async function flushAndNotify() {
|
||||
const result = await issueSync.flush();
|
||||
if (!result?.login || !result.receipts?.length ||
|
||||
!await issueSync.getReceiptPreference?.(result.login)) return;
|
||||
for (const receipt of result.receipts) {
|
||||
const needsAttention = receipt.status === 'attention';
|
||||
const needsAuthorization = receipt.status === 'authorization';
|
||||
const title = needsAuthorization ? 'Queued review needs authorization' : needsAttention
|
||||
? 'Queued work needs attention'
|
||||
: receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent';
|
||||
await self.registration.showNotification(title, {
|
||||
body: needsAuthorization ? 'Tap to authorize it in the Delivery center.' :
|
||||
needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.',
|
||||
tag: 'stackchain-delivery-' + receipt.id,
|
||||
data: { route: receipt.route },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify());
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
})());
|
||||
if (event.data?.type === 'stackchain-session-lease') {
|
||||
event.waitUntil(storeOfflineLease(event.data.expiresAt, event.data.idleExpiresAt));
|
||||
}
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('push', event => {
|
||||
let payload;
|
||||
try { payload = event.data?.json?.() || {}; }
|
||||
catch (_error) { return; }
|
||||
const route = String(payload.route || '');
|
||||
const protectRoute = String(payload.protect_route || '');
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
if (
|
||||
route === '#/my-work/agenda'
|
||||
&& protectRoute === '#/my-work/agenda/protect-today'
|
||||
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
|
||||
&& Number.isSafeInteger(deadlineCount)
|
||||
&& deadlineCount > 0
|
||||
&& deadlineCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(
|
||||
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
|
||||
{
|
||||
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
|
||||
tag,
|
||||
actions: [
|
||||
{ action: 'protect-today', title: 'Protect Today' },
|
||||
{ action: 'open-agenda', title: 'Open Agenda' },
|
||||
],
|
||||
data: {route, protectRoute},
|
||||
}
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
route === '#/my-work/updates'
|
||||
&& tag === 'stackchain-update-digest'
|
||||
&& Number.isSafeInteger(updateCount)
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
const options = {
|
||||
body: 'Tap to review it in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
};
|
||||
if (
|
||||
Number.isSafeInteger(notificationId) && notificationId > 0
|
||||
&& route === '#/my-work/update/' + notificationId
|
||||
&& tag === 'stackchain-update-' + notificationId
|
||||
) {
|
||||
options.actions = [
|
||||
{ action: 'mark-read', title: 'Mark read' },
|
||||
{ action: 'tomorrow', title: 'Tomorrow' },
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
const target = new URL(BASE + route, self.location.origin).href;
|
||||
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
|
||||
if (!client) return self.clients.openWindow(target);
|
||||
if (client.navigate) await client.navigate(target);
|
||||
return client.focus();
|
||||
}
|
||||
|
||||
self.addEventListener('notificationclick', event => {
|
||||
const route = String(event.notification.data?.route || '');
|
||||
if (event.action === 'protect-today') {
|
||||
const protectRoute = String(event.notification.data?.protectRoute || route);
|
||||
if (
|
||||
protectRoute !== '#/my-work/agenda/protect-today'
|
||||
|| !['#/my-work/agenda', protectRoute].includes(route)
|
||||
) return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(protectRoute));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'open-agenda') {
|
||||
if (route !== '#/my-work/agenda') return;
|
||||
event.notification.close();
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
return;
|
||||
}
|
||||
if (event.action === 'tomorrow') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
|| event.notification.tag !== 'stackchain-update-' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
|
||||
wake.setDate(wake.getDate() + 1);
|
||||
wake.setHours(9, 0, 0, 0);
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
|
||||
method: 'PATCH',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wake_at: wake.toISOString() }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
if (event.action === 'mark-read') {
|
||||
const notificationId = Number(event.notification.data?.notificationId);
|
||||
if (
|
||||
!Number.isSafeInteger(notificationId) || notificationId <= 0
|
||||
|| route !== '#/my-work/update/' + notificationId
|
||||
) return;
|
||||
event.waitUntil((async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
|
||||
try {
|
||||
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
|
||||
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
|
||||
});
|
||||
event.notification.close();
|
||||
} catch (_error) {
|
||||
await openWorkRoute(route);
|
||||
event.notification.close();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
})());
|
||||
return;
|
||||
}
|
||||
event.notification.close();
|
||||
if (!route.startsWith('#/my-work/')) return;
|
||||
event.waitUntil(openWorkRoute(route));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetchNavigation(request).then(async response => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const responseUrl = new URL(response.url || request.url);
|
||||
const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE;
|
||||
if (response.ok && !response.redirected && isDashboardShell) {
|
||||
await cache.put(BASE, response.clone());
|
||||
} else if (OUTAGE_STATUSES.has(response.status)) {
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return response;
|
||||
}).catch(async () => {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cachedShellWithValidLease(cache);
|
||||
return cached || new Response(
|
||||
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
|
||||
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,619 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else {
|
||||
const base = new URL('./', root.location.href).pathname;
|
||||
const originalFetch = root.fetch.bind(root);
|
||||
const boundary = factory({
|
||||
cookie: () => root.document.cookie,
|
||||
origin: root.location.origin,
|
||||
base,
|
||||
fetchImpl: originalFetch,
|
||||
localStorage: root.localStorage,
|
||||
sessionStorage: root.sessionStorage,
|
||||
indexedDB: root.indexedDB,
|
||||
caches: root.caches,
|
||||
serviceWorker: root.navigator?.serviceWorker,
|
||||
credentials: root.navigator?.credentials,
|
||||
MessageChannel: root.MessageChannel,
|
||||
location: root.location,
|
||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||
confirmAction: message => root.confirm(message),
|
||||
promptAuthorization: details => root.prompt(
|
||||
`Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`,
|
||||
),
|
||||
onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')),
|
||||
onClearError: error => {
|
||||
root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message }));
|
||||
root.alert('Signed out, but private queued work could not be cleared. Close other Stackchain tabs and clear this site’s data.');
|
||||
},
|
||||
});
|
||||
root.fetch = boundary.fetch;
|
||||
const attach = () => {
|
||||
boundary.startActivityHeartbeat();
|
||||
const button = root.document.getElementById('sign-out');
|
||||
if (button) button.addEventListener('click', () => boundary.signOut());
|
||||
const allDevicesButton = root.document.getElementById('sign-out-all');
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) {
|
||||
devicesButton.addEventListener('click', openSecurityCenter);
|
||||
}
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
};
|
||||
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
|
||||
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
|
||||
else attach();
|
||||
root.stackchainSession = boundary;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
||||
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
|
||||
promptAuthorization = () => null,
|
||||
onExpired = () => {},
|
||||
onClearError = () => {},
|
||||
requestTimeoutMs = 15000,
|
||||
activityHeartbeatIntervalMs = 60000,
|
||||
now = () => Date.now(),
|
||||
setTimer = (callback, delay) => setTimeout(callback, delay),
|
||||
}) {
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
let expirationStarted = false;
|
||||
let idleLockStarted = false;
|
||||
let lastActivityHeartbeatAt = Number.NEGATIVE_INFINITY;
|
||||
let activityHeartbeat = null;
|
||||
|
||||
async function handleRemoteRevocation() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
location.replace(base + 'login?reason=session-revoked');
|
||||
}
|
||||
|
||||
async function handleSessionExpiry() {
|
||||
if (expirationStarted) return;
|
||||
expirationStarted = true;
|
||||
await clearPrivateDeviceData();
|
||||
onExpired();
|
||||
location.replace(base + 'login?reason=session-expired');
|
||||
}
|
||||
|
||||
function handleSessionIdle() {
|
||||
if (expirationStarted || idleLockStarted) return;
|
||||
idleLockStarted = true;
|
||||
location.replace(base + 'login?reason=session-idle');
|
||||
}
|
||||
|
||||
async function handleServiceWorkerMessage(event) {
|
||||
if (event?.data?.type === 'stackchain-session-revoked') {
|
||||
await handleRemoteRevocation();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-idle') {
|
||||
handleSessionIdle();
|
||||
}
|
||||
if (event?.data?.type === 'stackchain-session-expired') {
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
|
||||
function storedOfflineLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function storedOfflineIdleLease() {
|
||||
const value = Number(localStorage?.getItem?.('stackchain.session-idle-expires-at'));
|
||||
return Number.isInteger(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
function scheduleOfflineExpiry(expiresAt) {
|
||||
const delay = Math.max(0, expiresAt * 1000 - now());
|
||||
setTimer(() => handleSessionExpiry(), delay);
|
||||
}
|
||||
|
||||
function scheduleOfflineIdle(idleExpiresAt) {
|
||||
const delay = Math.max(0, idleExpiresAt * 1000 - now());
|
||||
setTimer(() => {
|
||||
if (storedOfflineIdleLease() * 1000 <= now()) handleSessionIdle();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function publishOfflineLease(expiresAt, idleExpiresAt) {
|
||||
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
|
||||
localStorage?.setItem?.('stackchain.session-idle-expires-at', String(idleExpiresAt));
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({
|
||||
type: 'stackchain-session-lease', expiresAt, idleExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function csrfToken() {
|
||||
const entry = String(cookie?.() || '').split(';')
|
||||
.map(value => value.trim())
|
||||
.find(value => value.startsWith('stackchain_csrf='));
|
||||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
||||
}
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
|
||||
+ '='.repeat((4 - String(value).length % 4) % 4);
|
||||
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function encodeBase64Url(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
let binary = '';
|
||||
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
}
|
||||
|
||||
function authenticationCredentialJSON(credential) {
|
||||
return {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeBase64Url(credential.response.signature),
|
||||
userHandle: encodeBase64Url(credential.response.userHandle),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSameOrigin(input) {
|
||||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||
catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function isDashboardApi(input) {
|
||||
try {
|
||||
const url = new URL(String(input?.url || input), origin);
|
||||
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
async function fetchWithDeadline(input, options, method, phase = 'request') {
|
||||
if (!isDashboardApi(input)) return fetchImpl(input, options);
|
||||
const controller = new AbortController();
|
||||
const callerSignal = options.signal || input?.signal;
|
||||
let timeout;
|
||||
let rejectCancellation;
|
||||
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
|
||||
const cancelFromCaller = () => {
|
||||
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
|
||||
error.source = 'caller';
|
||||
controller.abort(error);
|
||||
rejectCancellation(error);
|
||||
};
|
||||
if (callerSignal?.aborted) cancelFromCaller();
|
||||
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
|
||||
const deadline = new Promise((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(
|
||||
SAFE_METHODS.has(method)
|
||||
? 'Request timed out. Try again.'
|
||||
: 'Request timed out. Refresh to verify the outcome before retrying.'
|
||||
);
|
||||
error.name = 'TimeoutError';
|
||||
error.method = method;
|
||||
error.outcome = 'unknown';
|
||||
error.safeToRetry = SAFE_METHODS.has(method);
|
||||
error.phase = phase;
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, requestTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
|
||||
deadline,
|
||||
cancellation,
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
|
||||
}
|
||||
}
|
||||
|
||||
async function authorizeWithPasskey(details) {
|
||||
if (!credentials?.get) return null;
|
||||
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ action: details.action, target: details.target }),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!optionsResponse.ok) return null;
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.get({ publicKey });
|
||||
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: authenticationCredentialJSON(credential),
|
||||
action: details.action,
|
||||
target: details.target,
|
||||
}),
|
||||
}, 'POST', 'passkey-authorization');
|
||||
if (!verified.ok) return null;
|
||||
const payload = await verified.json().catch(() => ({}));
|
||||
return payload.grant || null;
|
||||
}
|
||||
|
||||
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||
const requestOptions = { ...options };
|
||||
if (!SAFE_METHODS.has(method) && isSameOrigin(input)) {
|
||||
const headers = new Headers(options.headers || input?.headers || {});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) headers.set('X-CSRF-Token', csrf);
|
||||
requestOptions.headers = headers;
|
||||
}
|
||||
const response = await fetchWithDeadline(input, requestOptions, method);
|
||||
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
const detail = payload?.detail || {};
|
||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||
let grant = null;
|
||||
try { grant = await authorizeWithPasskey(detail); }
|
||||
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
|
||||
if (!grant) {
|
||||
const accessToken = await promptAuthorization({
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
});
|
||||
if (!accessToken) return response;
|
||||
const authorizationHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const csrf = csrfToken();
|
||||
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
|
||||
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
|
||||
method: 'POST',
|
||||
headers: authorizationHeaders,
|
||||
body: JSON.stringify({
|
||||
access_token: accessToken,
|
||||
action: detail.action,
|
||||
target: detail.target,
|
||||
}),
|
||||
}, 'POST', 'fresh-authorization');
|
||||
if (!authorization.ok) return authorization;
|
||||
const authorizationPayload = await authorization.json().catch(() => ({}));
|
||||
grant = authorizationPayload.grant;
|
||||
}
|
||||
if (!grant) return response;
|
||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
||||
retryHeaders.set('X-Step-Up-Grant', grant);
|
||||
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||
}
|
||||
}
|
||||
if (
|
||||
response.status === 401 && isSameOrigin(input)
|
||||
&& !expirationStarted && !idleLockStarted
|
||||
) {
|
||||
expirationStarted = true;
|
||||
const payload = await response.clone().json().catch(() => ({}));
|
||||
if (payload.code === 'session_revoked') {
|
||||
expirationStarted = false;
|
||||
await handleRemoteRevocation();
|
||||
} else if (payload.code === 'session_idle') {
|
||||
expirationStarted = false;
|
||||
handleSessionIdle();
|
||||
} else {
|
||||
expirationStarted = false;
|
||||
await handleSessionExpiry();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function recordActivity() {
|
||||
const current = now();
|
||||
if (
|
||||
expirationStarted
|
||||
|| idleLockStarted
|
||||
|| activityHeartbeat
|
||||
|| current - lastActivityHeartbeatAt < activityHeartbeatIntervalMs
|
||||
) return Promise.resolve(false);
|
||||
lastActivityHeartbeatAt = current;
|
||||
activityHeartbeat = sessionFetch(base + 'api/v1/session/activity', { method: 'POST' })
|
||||
.then(async response => {
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt && Number.isInteger(payload.idle_expires_at)) {
|
||||
await publishOfflineLease(expiresAt, payload.idle_expires_at);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => { activityHeartbeat = null; });
|
||||
return activityHeartbeat;
|
||||
}
|
||||
|
||||
function startActivityHeartbeat() {
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach(type => {
|
||||
addActivityListener?.(type, recordActivity, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshOfflineLease() {
|
||||
try {
|
||||
const response = await sessionFetch(base + 'api/v1/session');
|
||||
if (!response.ok) return false;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (
|
||||
!payload.authenticated
|
||||
|| !Number.isInteger(payload.expires_at)
|
||||
|| !Number.isInteger(payload.idle_expires_at)
|
||||
) return false;
|
||||
await publishOfflineLease(payload.expires_at, payload.idle_expires_at);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
const expiresAt = storedOfflineLease();
|
||||
if (expiresAt * 1000 <= now()) {
|
||||
if (expiresAt) await handleSessionExpiry();
|
||||
return false;
|
||||
}
|
||||
const idleExpiresAt = storedOfflineIdleLease();
|
||||
if (!idleExpiresAt || idleExpiresAt * 1000 <= now()) {
|
||||
handleSessionIdle();
|
||||
return false;
|
||||
}
|
||||
scheduleOfflineExpiry(expiresAt);
|
||||
scheduleOfflineIdle(idleExpiresAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function removeDashboardStorage(storage) {
|
||||
if (!storage) return;
|
||||
const keys = [];
|
||||
try {
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (key?.startsWith('stackchain.')) keys.push(key);
|
||||
}
|
||||
keys.forEach(key => storage.removeItem(key));
|
||||
} catch (_error) { /* Cookie invalidation still protects server data. */ }
|
||||
}
|
||||
|
||||
async function stopWorkerOutbox() {
|
||||
const registration = await serviceWorker?.ready;
|
||||
if (!registration?.active || !MessageChannel) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = setTimeout(() => reject(new Error('Background outbox purge timed out.')), 3000);
|
||||
channel.port1.onmessage = event => {
|
||||
clearTimeout(timeout);
|
||||
if (event.data?.ok) resolve();
|
||||
else reject(new Error(event.data?.error || 'Background outbox purge failed.'));
|
||||
};
|
||||
registration.active.postMessage({ type: 'stackchain-purge-outbox' }, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function clearPrivateDeviceData() {
|
||||
removeDashboardStorage(localStorage);
|
||||
if (sessionStorage !== localStorage) removeDashboardStorage(sessionStorage);
|
||||
try {
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)));
|
||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
||||
}
|
||||
|
||||
async function enrollPasskey() {
|
||||
if (!credentials?.create) throw new Error('Passkeys are not supported');
|
||||
const optionsResponse = await sessionFetch(
|
||||
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
|
||||
);
|
||||
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
|
||||
const publicKey = await optionsResponse.json();
|
||||
const challenge = publicKey.challenge;
|
||||
publicKey.challenge = decodeBase64Url(publicKey.challenge);
|
||||
publicKey.user.id = decodeBase64Url(publicKey.user.id);
|
||||
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
|
||||
...item, id: decodeBase64Url(item.id),
|
||||
}));
|
||||
const credential = await credentials.create({ publicKey });
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
type: credential.type,
|
||||
rawId: encodeBase64Url(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeBase64Url(credential.response.attestationObject),
|
||||
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports?.() || [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Could not verify passkey enrollment');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function listActiveDevices() {
|
||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||
if (!response.ok) throw new Error('Could not load active devices');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.devices) ? payload.devices : [];
|
||||
}
|
||||
|
||||
async function listPasskeys() {
|
||||
const response = await sessionFetch(base + 'api/v1/passkeys');
|
||||
if (!response.ok) throw new Error('Could not load enrolled passkeys');
|
||||
const payload = await response.json();
|
||||
return Array.isArray(payload.passkeys) ? payload.passkeys : [];
|
||||
}
|
||||
|
||||
async function revokePasskey(passkey) {
|
||||
if (!passkey?.management_id) return false;
|
||||
const confirmed = confirmAction?.(
|
||||
`Remove the passkey for ${passkey.device_label}? This cannot be undone.`
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/passkeys/' + encodeURIComponent(passkey.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not remove enrolled passkey');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatAuthenticationAlert(alert) {
|
||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
||||
'en-US', { timeZone: 'UTC' },
|
||||
);
|
||||
return {
|
||||
title,
|
||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
|
||||
if (!response.ok) throw new Error('Could not load security activity');
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
||||
? payload.authentication_alerts : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function revokeActiveDevice(device) {
|
||||
if (!device?.management_id || device.current) return false;
|
||||
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(
|
||||
base + 'api/v1/sessions/' + encodeURIComponent(device.management_id),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error('Could not revoke active device');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await sessionFetch(base + 'api/v1/session', { method: 'DELETE' });
|
||||
} finally {
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
}
|
||||
}
|
||||
|
||||
async function signOutAllDevices() {
|
||||
const confirmed = confirmAction?.('Sign out every device? You will need to sign in again everywhere.');
|
||||
if (!confirmed) return false;
|
||||
const response = await sessionFetch(base + 'api/v1/sessions', { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error('Could not sign out all devices');
|
||||
await clearPrivateDeviceData();
|
||||
location.assign(base + 'login');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resumeQueuedWork() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({ type: 'stackchain-resume-outbox' });
|
||||
} catch (_error) { /* Background Sync is optional; foreground delivery remains available. */ }
|
||||
}
|
||||
|
||||
return {
|
||||
fetch: sessionFetch,
|
||||
signOut,
|
||||
signOutAllDevices,
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
formatAuthenticationAlert,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
clearPrivateDeviceData,
|
||||
handleServiceWorkerMessage,
|
||||
refreshOfflineLease,
|
||||
resumeQueuedWork,
|
||||
recordActivity,
|
||||
startActivityHeartbeat,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTaskOverlayHistory = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview', 'today-readiness']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
let started = false;
|
||||
|
||||
function stateKind(state = history.state) {
|
||||
return allowed.has(state?.taskOverlay) ? state.taskOverlay : null;
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
const next = stateKind(state);
|
||||
if (next === active) return;
|
||||
const previous = active;
|
||||
active = next;
|
||||
onChange(next, previous);
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', event => apply(event.state));
|
||||
},
|
||||
open(kind) {
|
||||
if (!allowed.has(kind)) return false;
|
||||
if (active === kind) return true;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}), taskOverlay: kind };
|
||||
history.pushState(state, '');
|
||||
active = kind;
|
||||
onChange(kind, previous);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (!active) return false;
|
||||
history.back();
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
if (!active) return false;
|
||||
const previous = active;
|
||||
const state = { ...(history.state || {}) };
|
||||
delete state.taskOverlay;
|
||||
history.replaceState(state, '');
|
||||
active = null;
|
||||
onChange(null, previous);
|
||||
return true;
|
||||
},
|
||||
current() { return active; },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
function createTodayCompletion({ todayWork, todaySync, workSession, refresh, warm, announce, advance = null }) {
|
||||
return function completeTodayItem(item, options = {}) {
|
||||
if (!item || !todayWork.remove(item)) {
|
||||
announce(options.failureMessage || 'Could not update Today on this device. Try again.');
|
||||
return false;
|
||||
}
|
||||
todaySync.enqueue('remove', todayWork.identity(item));
|
||||
todaySync.flush();
|
||||
refresh();
|
||||
warm();
|
||||
(advance || (() => workSession.complete()))();
|
||||
announce(options.successMessage || 'Done for Today. The Gitea item is unchanged.');
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayCompletion;
|
||||
@@ -0,0 +1,93 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createTodayReadiness = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createTodayReadiness({ inspect, onOpen = () => {}, onGate = () => {}, onPreview = () => {} }) {
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
|
||||
async function classify(item) {
|
||||
if (item?.kind !== 'issue') return { status:'ready', dependencies:[] };
|
||||
try {
|
||||
const detail = await inspect(item);
|
||||
if (detail?.available !== true) return { status:'unknown', dependencies:[] };
|
||||
const dependencies = Array.isArray(detail.dependencies)
|
||||
? detail.dependencies.filter(dependency => dependency?.state === 'open')
|
||||
: [];
|
||||
return { status:dependencies.length ? 'blocked' : 'ready', dependencies };
|
||||
} catch (_error) {
|
||||
return { status:'unknown', dependencies:[] };
|
||||
}
|
||||
}
|
||||
|
||||
async function run(action, items, target = null) {
|
||||
const request = ++generation;
|
||||
const ordered = Array.isArray(items) ? items.slice() : [];
|
||||
const preferred = target || ordered[0] || null;
|
||||
if (!preferred) return 'empty';
|
||||
const preferredIndex = ordered.indexOf(preferred);
|
||||
const candidates = preferredIndex >= 0
|
||||
? ordered.slice(preferredIndex).concat(ordered.slice(0, preferredIndex))
|
||||
: [preferred].concat(ordered);
|
||||
const targetState = { item:preferred, ...(await classify(preferred)) };
|
||||
if (request !== generation) return 'superseded';
|
||||
if (targetState.status === 'ready') {
|
||||
pending = null;
|
||||
onOpen(action, preferred);
|
||||
return 'opened';
|
||||
}
|
||||
const fallbackStates = await Promise.all(candidates.slice(1).map(async item => ({
|
||||
item,
|
||||
...(await classify(item)),
|
||||
})));
|
||||
const states = [targetState, ...fallbackStates];
|
||||
if (request !== generation) return 'superseded';
|
||||
const nextReadyState = states.slice(1).find(state => state.status === 'ready');
|
||||
pending = {
|
||||
action,
|
||||
items:ordered,
|
||||
target:preferred,
|
||||
status:targetState.status,
|
||||
dependencies:targetState.dependencies,
|
||||
nextReady:nextReadyState?.item || null,
|
||||
};
|
||||
onGate({ ...pending, items:pending.items.slice() });
|
||||
return 'gated';
|
||||
}
|
||||
|
||||
function open(item) {
|
||||
if (!pending || !item) return false;
|
||||
const action = pending.action;
|
||||
pending = null;
|
||||
generation += 1;
|
||||
onOpen(action, item);
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewBlocker(blocker) {
|
||||
if (!pending || !blocker) return false;
|
||||
const current = pending.dependencies.find(dependency =>
|
||||
dependency?.repository === blocker.repository && Number(dependency?.number) === Number(blocker.number)
|
||||
);
|
||||
if (!current) return false;
|
||||
onPreview(current);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
retry: () => pending ? run(pending.action, pending.items, pending.target) : Promise.resolve('closed'),
|
||||
previewBlocker,
|
||||
startNextReady: () => open(pending?.nextReady),
|
||||
workAnyway: () => open(pending?.target),
|
||||
cancel() {
|
||||
generation += 1;
|
||||
pending = null;
|
||||
},
|
||||
snapshot: () => pending ? { ...pending, items:pending.items.slice() } : { open:false },
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
function todayRecapFeedbackRows(draft, describe = () => null) {
|
||||
return (draft?.items || []).map(item => {
|
||||
const work = describe(item.identity) || {};
|
||||
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
|
||||
return {
|
||||
identity:item.identity,
|
||||
label:String(work.title || item.identity).slice(0, 180),
|
||||
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
|
||||
estimate_minutes:estimate,
|
||||
actual_minutes:item.actual_minutes,
|
||||
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
|
||||
const totals = items => {
|
||||
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
|
||||
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
|
||||
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
|
||||
};
|
||||
const storageKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
|
||||
};
|
||||
const handoffKey = () => {
|
||||
const login = String(getLogin() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
|
||||
};
|
||||
const validStoredDraft = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
|
||||
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
|
||||
item.identity.length <= 500 && (item.estimate_minutes === null ||
|
||||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
|
||||
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
|
||||
if (!itemsValid) return false;
|
||||
const identities = new Set(saved.items.map(item => item.identity));
|
||||
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
|
||||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
|
||||
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
|
||||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
|
||||
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
|
||||
return true;
|
||||
};
|
||||
const load = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validStoredDraft(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { ...saved, ...totals(saved.items) };
|
||||
}
|
||||
catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const validHandoff = saved => {
|
||||
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
|
||||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
|
||||
const entries = Object.entries(saved.actual_minutes);
|
||||
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
|
||||
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
|
||||
);
|
||||
};
|
||||
const pendingReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (!storage || !key) return null;
|
||||
try {
|
||||
const saved = JSON.parse(storage.getItem(key) || 'null');
|
||||
if (!validHandoff(saved)) {
|
||||
if (saved !== null) storage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
|
||||
} catch (_error) {
|
||||
try { storage.removeItem(key); } catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const completeReplan = () => {
|
||||
const key = handoffKey();
|
||||
if (storage && key) storage.removeItem(key);
|
||||
};
|
||||
let draftKey = storageKey();
|
||||
let draft = load();
|
||||
const persist = () => {
|
||||
const key = storageKey();
|
||||
if (!storage || !key || !draft) return;
|
||||
const saved = { session_id:draft.session_id, items:draft.items };
|
||||
if (draft.log_identities) saved.log_identities = draft.log_identities;
|
||||
if (draft.time_logs) saved.time_logs = draft.time_logs;
|
||||
storage.setItem(key, JSON.stringify(saved));
|
||||
};
|
||||
const restore = () => {
|
||||
const key = storageKey();
|
||||
if (key !== draftKey) {
|
||||
draft = null;
|
||||
draftKey = key;
|
||||
}
|
||||
if (!draft) draft = load();
|
||||
return Boolean(draft);
|
||||
};
|
||||
const snapshot = () => {
|
||||
restore();
|
||||
return draft ? {
|
||||
...draft, items:draft.items.map(item => ({...item})),
|
||||
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
|
||||
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
|
||||
} : null;
|
||||
};
|
||||
const saveConfirmed = async includeActuals => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
|
||||
const actualMinutes = includeActuals ? Object.fromEntries(
|
||||
payload.items.map(item => [item.identity, item.actual_minutes])
|
||||
) : null;
|
||||
const result = await save(payload);
|
||||
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
|
||||
session_id:payload.session_id, actual_minutes:actualMinutes,
|
||||
}));
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
|
||||
};
|
||||
const saveWithTime = async identities => {
|
||||
restore();
|
||||
if (!draft?.items.length) throw new Error('No timed work to save.');
|
||||
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
|
||||
const requested = new Set(Array.isArray(identities) ? identities : []);
|
||||
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
|
||||
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
|
||||
.filter(identity => eligible.has(identity));
|
||||
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
|
||||
draft.log_identities = intended;
|
||||
draft.time_logs = draft.time_logs || {};
|
||||
persist();
|
||||
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
|
||||
const payload = {
|
||||
session_id:draft.session_id,
|
||||
items:draft.items.map(item => ({...item})),
|
||||
log_identities:logIdentities,
|
||||
};
|
||||
const result = await saveAndLog(payload);
|
||||
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
|
||||
persist();
|
||||
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
|
||||
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
|
||||
const retry = intended.length - logged - verify;
|
||||
if (retry || verify) {
|
||||
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
|
||||
' verification in Gitea; it will not be posted again automatically.');
|
||||
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
|
||||
}
|
||||
clear();
|
||||
if (storage && draftKey) storage.removeItem(draftKey);
|
||||
draft = null;
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
restore,
|
||||
begin(entries, estimates = {}) {
|
||||
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
|
||||
identity:String(entry.identity),
|
||||
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
|
||||
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
|
||||
}));
|
||||
draftKey = storageKey();
|
||||
draft = { session_id:makeId(), items, ...totals(items) };
|
||||
persist();
|
||||
return snapshot();
|
||||
},
|
||||
correct(identity, minutes) {
|
||||
restore();
|
||||
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
|
||||
const item = draft.items.find(candidate => candidate.identity === identity);
|
||||
if (!item) return false;
|
||||
item.actual_minutes = minutes;
|
||||
Object.assign(draft, totals(draft.items));
|
||||
persist();
|
||||
return true;
|
||||
},
|
||||
snapshot,
|
||||
save:() => saveConfirmed(false),
|
||||
saveForReplan:() => saveConfirmed(true),
|
||||
saveAndLog:saveWithTime,
|
||||
pendingReplan,
|
||||
completeReplan,
|
||||
discardReplan:completeReplan,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTodayRecap(payload) {
|
||||
const response = await fetch('api/v1/today/recaps', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
|
||||
return result;
|
||||
}
|
||||
|
||||
async function saveTodayRecapTime(payload) {
|
||||
const response = await fetch('api/v1/today/recaps/log-time', {
|
||||
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
|
||||
body:JSON.stringify(payload),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
|
||||
return result;
|
||||
}
|
||||
|
||||
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
|
||||
describeWork = () => null, adjustPlan = () => {} }) {
|
||||
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||
const render = () => {
|
||||
const draft = recap.snapshot();
|
||||
const container = qs('#today-recap-items');
|
||||
const rows = todayRecapFeedbackRows(draft, describeWork);
|
||||
container.innerHTML = rows.map(item => {
|
||||
const variance = item.variance_minutes === null ? 'No estimate comparison' :
|
||||
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
|
||||
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
|
||||
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
|
||||
const logStatus = draft?.time_logs?.[item.identity];
|
||||
const logControl = logStatus === 'logged' ?
|
||||
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
|
||||
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
|
||||
item.actual_minutes > 0 && canonical ?
|
||||
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
|
||||
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
|
||||
minutes(item.actual_minutes) + ' to Gitea</label>' :
|
||||
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
|
||||
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
|
||||
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
|
||||
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
|
||||
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
|
||||
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
|
||||
}).join('') || '';
|
||||
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
||||
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
||||
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
||||
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
|
||||
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
|
||||
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
|
||||
}
|
||||
render();
|
||||
}));
|
||||
};
|
||||
const loadHistory = async () => {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
|
||||
try {
|
||||
const result = await api('api/v1/today/recaps');
|
||||
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
|
||||
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
|
||||
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
|
||||
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
|
||||
).join('') || '<div class="small">No saved recaps yet.</div>';
|
||||
} catch (_error) {
|
||||
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
|
||||
}
|
||||
};
|
||||
const open = ({ begin = false } = {}) => {
|
||||
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
|
||||
else recap.restore();
|
||||
render();
|
||||
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
|
||||
qs('#today-recap-sheet').hidden = false;
|
||||
document.body.classList.add('task-overlay-open');
|
||||
loadHistory();
|
||||
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
|
||||
};
|
||||
const close = () => {
|
||||
qs('#today-recap-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
qs('#open-today-recaps').focus();
|
||||
};
|
||||
const saveDraft = async button => {
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||
try {
|
||||
const handoff = await recap.saveForReplan();
|
||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(handoff.actual_minutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const saveTimeDraft = async button => {
|
||||
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
|
||||
.map(input => input.dataset.recapLogIdentity);
|
||||
if (!selected.length) {
|
||||
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
|
||||
return;
|
||||
}
|
||||
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
|
||||
button.disabled = true;
|
||||
qs('#today-recap-status').textContent = 'Logging selected time…';
|
||||
try {
|
||||
await recap.saveAndLog(selected);
|
||||
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
|
||||
await loadHistory(); render(); button.hidden = true;
|
||||
close();
|
||||
adjustPlan(actualMinutes);
|
||||
} catch (error) {
|
||||
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
|
||||
render();
|
||||
} finally { button.disabled = false; }
|
||||
};
|
||||
const bind = () => {
|
||||
qs('#open-today-recaps').addEventListener('click', () => open());
|
||||
qs('#close-today-recap').addEventListener('click', close);
|
||||
qs('#discard-today-recap').addEventListener('click', close);
|
||||
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
|
||||
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
|
||||
};
|
||||
return {
|
||||
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
|
||||
pendingReplan:recap.pendingReplan,
|
||||
completeReplan:recap.completeReplan,
|
||||
discardReplan:recap.discardReplan,
|
||||
};
|
||||
}
|
||||
|
||||
function endTodaySession(workSession, qs) {
|
||||
workSession.end();
|
||||
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
|
||||
}
|
||||
|
||||
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||
timerView.finish();
|
||||
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
|
||||
qs('#my-work-action-status').textContent = 'Work session complete.';
|
||||
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
|
||||
else qs('#start-work-session').focus();
|
||||
}
|
||||
|
||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||
describeWork, adjustPlan) {
|
||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||
const recap = createTodayRecap({
|
||||
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
|
||||
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
||||
});
|
||||
const view = createTodayRecapView({ ...options, recap });
|
||||
view.bind();
|
||||
setInterval(timerView.render, 1000);
|
||||
view.finish = workFilter => {
|
||||
closeSheets();
|
||||
updateActions();
|
||||
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayRecap.createView = createTodayRecapView;
|
||||
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
||||
module.exports = createTodayRecap;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
function createTodayRollover(options = {}) {
|
||||
const now = options.now || (() => new Date());
|
||||
const resolvedTimeZone = options.resolvedTimeZone || (() =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
if (options.localDate) return options.localDate();
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(now());
|
||||
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
return `${value.year}-${value.month}-${value.day}`;
|
||||
}
|
||||
|
||||
function reviewState(plan) {
|
||||
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
|
||||
if (!plan.plan_date) return 'legacy';
|
||||
return plan.plan_date === localDate() ? 'current' : 'stale';
|
||||
}
|
||||
|
||||
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
|
||||
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
|
||||
const selected = new Set(ids);
|
||||
return {
|
||||
operation_id, action: 'rollover', item_id: 'plan', base_revision,
|
||||
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
|
||||
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
|
||||
};
|
||||
}
|
||||
|
||||
return { localDate, timeZone, reviewState, operation };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
|
||||
@@ -0,0 +1,309 @@
|
||||
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePlan, onStatus, createOperationId, createChannel, coordinator,
|
||||
setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, retryBaseMs = 1000, retryMaxMs = 30000,
|
||||
now = Date.now, maxOfflineMs = 30 * 24 * 60 * 60 * 1000 }) {
|
||||
const prefix = 'stackchain.today-sync.v1.';
|
||||
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||
const snapshotPrefix = 'stackchain.today-sync-snapshot.v1.';
|
||||
let flushing = null;
|
||||
let channel = null;
|
||||
let channelKey = '';
|
||||
let retryTimer = null;
|
||||
let retryAttempt = 0;
|
||||
let expiredCount = 0;
|
||||
const knownOperationKeys = new Set();
|
||||
|
||||
function cancelRetry() {
|
||||
if (retryTimer !== null) clearTimer?.(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
|
||||
function scheduleRetry(error, ownerKey) {
|
||||
if (retryTimer !== null || !pending().length || !ownerKey) return;
|
||||
const advised = Number(error?.retryAfter);
|
||||
const delayMs = Number.isFinite(advised) && advised >= 0
|
||||
? advised * 1000
|
||||
: Math.min(retryMaxMs, retryBaseMs * (2 ** retryAttempt));
|
||||
retryAttempt += 1;
|
||||
onStatus?.('retrying', { delayMs });
|
||||
retryTimer = setTimer?.(async () => {
|
||||
retryTimer = null;
|
||||
if (key() !== ownerKey) return false;
|
||||
return flush();
|
||||
}, delayMs);
|
||||
retryTimer?.unref?.();
|
||||
}
|
||||
|
||||
function key() {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? prefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function snapshotKey() {
|
||||
const storageKey = key();
|
||||
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||
}
|
||||
|
||||
function savedRevision() {
|
||||
try {
|
||||
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||
} catch (_error) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = {
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureChannel() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || channelKey === storageKey) return;
|
||||
channel?.close?.();
|
||||
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||
? name => new globalThis.window.BroadcastChannel(name)
|
||||
: null);
|
||||
channelKey = storageKey;
|
||||
channel = factory?.('stackchain-today-' + storageKey.slice(prefix.length)) || null;
|
||||
channel?.addEventListener?.('message', event => {
|
||||
if (key() === storageKey) adopt(event.data, false);
|
||||
});
|
||||
}
|
||||
|
||||
function pending() {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return [];
|
||||
const recordPrefix = storageKey + '.operation.';
|
||||
try {
|
||||
const legacy = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||
if (Array.isArray(legacy)) {
|
||||
legacy.forEach((operation, index) => {
|
||||
if (!operation?.operation_id) return;
|
||||
const recordKey = recordPrefix + encodeURIComponent(operation.operation_id);
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: index }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
});
|
||||
if (legacy.length) storage.removeItem(storageKey);
|
||||
}
|
||||
const keys = new Set([...knownOperationKeys].filter(candidate => candidate.startsWith(recordPrefix)));
|
||||
for (let index = 0; index < Number(storage.length || 0); index += 1) {
|
||||
const candidate = storage.key?.(index);
|
||||
if (candidate?.startsWith(recordPrefix)) keys.add(candidate);
|
||||
}
|
||||
const records = [...keys].map(recordKey => {
|
||||
const record = JSON.parse(storage.getItem(recordKey) || 'null');
|
||||
return record ? { ...record, recordKey } : null;
|
||||
}).filter(record => record?.operation);
|
||||
const expired = records.filter(record => Number(record.queued_at) >= 1_000_000_000_000 &&
|
||||
now() - Number(record.queued_at) > maxOfflineMs);
|
||||
expired.forEach(record => {
|
||||
storage.removeItem(record.recordKey);
|
||||
knownOperationKeys.delete(record.recordKey);
|
||||
});
|
||||
if (expired.length) {
|
||||
expiredCount += expired.length;
|
||||
onStatus?.('expired', { count: expiredCount });
|
||||
}
|
||||
return records.filter(record => !expired.includes(record))
|
||||
.sort((left, right) => Number(left.queued_at || 0) - Number(right.queued_at || 0) ||
|
||||
left.operation.operation_id.localeCompare(right.operation.operation_id))
|
||||
.map(record => ({
|
||||
...record.operation,
|
||||
base_revision: Number.isInteger(record.operation.base_revision)
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function removeOperation(operationId) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operationId);
|
||||
try {
|
||||
storage.removeItem(recordKey);
|
||||
knownOperationKeys.delete(recordKey);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function operationId() {
|
||||
if (createOperationId) return createOperationId();
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
function enqueue(action, itemId, direction = null) {
|
||||
const operations = pending();
|
||||
if (action === 'remove' && operations.some(operation =>
|
||||
operation.action === 'remove' && operation.item_id === itemId
|
||||
)) {
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
}
|
||||
const operation = {
|
||||
operation_id: operationId(), action, item_id: itemId, direction,
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
let saved = false;
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
saved = true;
|
||||
coordinator?.notify('today');
|
||||
} catch (_error) { /* Report the persistence failure below. */ }
|
||||
onStatus?.(saved ? 'pending' : 'error');
|
||||
return saved;
|
||||
}
|
||||
|
||||
function enqueueConfiguration(capacityMinutes, estimates) {
|
||||
const operation = {
|
||||
operation_id: operationId(), action: 'configure', item_id: 'plan', direction: null,
|
||||
capacity_minutes: capacityMinutes, estimates: estimates || {},
|
||||
base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||
try {
|
||||
if (storage.getItem(marker)) return false;
|
||||
for (const id of ids || []) enqueue('add', id);
|
||||
storage.setItem(marker, '1');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey) return false;
|
||||
ensureChannel();
|
||||
expiredCount = 0;
|
||||
try {
|
||||
let operations = pending();
|
||||
let plan;
|
||||
let hadConflict = false;
|
||||
if (!operations.length) plan = await fetchJson('api/v1/today');
|
||||
while (operations.length) {
|
||||
if (key() !== ownerKey) return false;
|
||||
const batch = operations.slice(0, 50);
|
||||
plan = await fetchJson('api/v1/today', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operations: batch }),
|
||||
});
|
||||
const hasReceipts = Array.isArray(plan.accepted_operation_ids) ||
|
||||
Array.isArray(plan.duplicate_operation_ids) || Array.isArray(plan.rejected_operations);
|
||||
const received = hasReceipts ? [
|
||||
...(plan.accepted_operation_ids || []),
|
||||
...(plan.duplicate_operation_ids || []),
|
||||
...(plan.rejected_operations || []).map(item => item.operation_id),
|
||||
] : batch.map(item => item.operation_id);
|
||||
hadConflict = hadConflict || Boolean(plan.rejected_operations?.length);
|
||||
for (const operationId of received) {
|
||||
if (pending().some(candidate => candidate.operation_id === operationId) &&
|
||||
!removeOperation(operationId)) {
|
||||
throw new Error('Could not persist Today delivery receipt');
|
||||
}
|
||||
}
|
||||
operations = pending();
|
||||
}
|
||||
adopt(plan);
|
||||
onStatus?.(pending().length ? 'pending' : hadConflict ? 'full' :
|
||||
expiredCount ? 'expired' : 'saved', expiredCount ? { count: expiredCount } : {});
|
||||
retryAttempt = 0;
|
||||
cancelRetry();
|
||||
return !hadConflict;
|
||||
} catch (error) {
|
||||
if (pending().length) scheduleRetry(error, ownerKey);
|
||||
else onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (!flushing) {
|
||||
const delivery = coordinator ? coordinator.runExclusive('today', run) : run();
|
||||
flushing = Promise.resolve(delivery).finally(() => { flushing = null; });
|
||||
}
|
||||
return flushing;
|
||||
}
|
||||
|
||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||
windowObject?.addEventListener?.('online', flush);
|
||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||
documentObject.hidden ? false : flush()
|
||||
);
|
||||
}
|
||||
|
||||
coordinator?.subscribe(change => {
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
@@ -0,0 +1,374 @@
|
||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
const key = () => {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||
};
|
||||
const empty = () => ({
|
||||
version:1, active_identity:'', entries:{}, away_at:null,
|
||||
pending_interruption:null, attention_interruption:null,
|
||||
});
|
||||
const read = () => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return empty();
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
|
||||
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
|
||||
!value.entries || typeof value.entries !== 'object') return empty();
|
||||
return value;
|
||||
} catch (_error) {
|
||||
return empty();
|
||||
}
|
||||
};
|
||||
const write = state => {
|
||||
const ownerKey = key();
|
||||
if (!ownerKey || !storage) return false;
|
||||
try {
|
||||
storage.setItem(ownerKey, JSON.stringify(state));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const validPending = state => {
|
||||
const pending = state.pending_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
|
||||
{ identity:pending.identity, away_ms:pending.away_ms } : null;
|
||||
};
|
||||
const validAttention = state => {
|
||||
const pending = state.attention_interruption;
|
||||
return pending && typeof pending.identity === 'string' && pending.identity &&
|
||||
typeof pending.resume === 'boolean' ?
|
||||
{ identity:pending.identity, resume:pending.resume } : null;
|
||||
};
|
||||
const settle = (state, at = now()) => {
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running) return state;
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
|
||||
Math.max(0, at - Number(entry.started_at ?? at));
|
||||
entry.started_at = null;
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const state = read();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
|
||||
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
|
||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||
};
|
||||
return {
|
||||
activate(identity) {
|
||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||
const state = read();
|
||||
if (state.active_identity === identity && state.entries[identity]?.running) return true;
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.entries[identity] = entry;
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
pause() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
resume() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry) return false;
|
||||
if (!entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
return write(state);
|
||||
},
|
||||
stop() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
return write(state);
|
||||
},
|
||||
beginAttention() {
|
||||
const state = read();
|
||||
const existing = validAttention(state);
|
||||
if (existing) return existing;
|
||||
const identity = state.active_identity;
|
||||
const entry = state.entries[identity];
|
||||
if (!identity || !entry) return null;
|
||||
const resume = Boolean(entry.running);
|
||||
if (resume) settle(state);
|
||||
state.away_at = null;
|
||||
state.attention_interruption = { identity, resume };
|
||||
return write(state) ? { ...state.attention_interruption } : null;
|
||||
},
|
||||
attentionInterruption() {
|
||||
return validAttention(read());
|
||||
},
|
||||
returnFromAttention() {
|
||||
const state = read();
|
||||
const pending = validAttention(state);
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!pending || !entry) return null;
|
||||
state.active_identity = pending.identity;
|
||||
if (pending.resume && !entry.running) {
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
}
|
||||
state.attention_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
|
||||
},
|
||||
markAway() {
|
||||
const state = read();
|
||||
const entry = state.entries[state.active_identity];
|
||||
if (!entry?.running || validPending(state)) return false;
|
||||
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
|
||||
state.away_at = now();
|
||||
return write(state);
|
||||
},
|
||||
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
|
||||
const state = read();
|
||||
const existing = validPending(state);
|
||||
if (existing) return existing;
|
||||
state.pending_interruption = null;
|
||||
const entry = state.entries[state.active_identity];
|
||||
const awayAt = Number(state.away_at);
|
||||
const detectedAt = now();
|
||||
state.away_at = null;
|
||||
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
|
||||
detectedAt - awayAt < thresholdMs) {
|
||||
write(state);
|
||||
return null;
|
||||
}
|
||||
const awayMs = detectedAt - awayAt;
|
||||
settle(state, detectedAt);
|
||||
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
|
||||
write(state);
|
||||
return { ...state.pending_interruption };
|
||||
},
|
||||
pendingInterruption() {
|
||||
return validPending(read());
|
||||
},
|
||||
resolveInterruption(decision) {
|
||||
if (decision !== 'count' && decision !== 'exclude') return false;
|
||||
const state = read();
|
||||
const pending = state.pending_interruption;
|
||||
const entry = pending && state.entries[pending.identity];
|
||||
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
|
||||
if (decision === 'exclude') {
|
||||
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
|
||||
}
|
||||
state.active_identity = pending.identity;
|
||||
entry.started_at = now();
|
||||
entry.running = true;
|
||||
state.pending_interruption = null;
|
||||
state.away_at = null;
|
||||
return write(state);
|
||||
},
|
||||
recapEntries() {
|
||||
const state = read();
|
||||
settle(state);
|
||||
write(state);
|
||||
return Object.entries(state.entries).map(([identity, entry]) => ({
|
||||
identity,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
|
||||
})).filter(entry => entry.elapsed_ms > 0);
|
||||
},
|
||||
clearRecap() {
|
||||
return write(empty());
|
||||
},
|
||||
totalElapsed() {
|
||||
const state = read();
|
||||
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
|
||||
const live = identity === state.active_identity && entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
|
||||
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onReopen?.(identity);
|
||||
})
|
||||
);
|
||||
queryAll('[data-mobile-today-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => view.toggle())
|
||||
);
|
||||
queryAll('[data-mobile-today-complete]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onComplete?.(identity);
|
||||
})
|
||||
);
|
||||
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.hidden = true;
|
||||
button.dataset.workSessionAdjustPlan = '';
|
||||
button.textContent = 'Adjust remaining plan';
|
||||
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
|
||||
});
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const plan = queryAll('#plan-today')[0];
|
||||
const sheet = queryAll('#plan-today-sheet')[0];
|
||||
if (plan && sheet) {
|
||||
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button =>
|
||||
button.addEventListener('click', () => replan.open())
|
||||
);
|
||||
new MutationObserver(() => {
|
||||
if (sheet.hidden && replan.restore()) render();
|
||||
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
|
||||
}
|
||||
}
|
||||
const elapsed = milliseconds => {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainder = String(seconds % 60).padStart(2, '0');
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
|
||||
};
|
||||
const render = () => {
|
||||
const snapshot = timer.snapshot();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
queryAll('[data-mobile-today-complete]').forEach(button => {
|
||||
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
|
||||
const finalItem = Boolean(progress && progress.index >= progress.total);
|
||||
button.hidden = !active;
|
||||
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
|
||||
button.setAttribute('aria-label', finalItem ?
|
||||
'Complete ' + itemTitle + ' and open Today recap' :
|
||||
'Complete ' + itemTitle + ' and open next Today item');
|
||||
});
|
||||
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
|
||||
if (!progress) return;
|
||||
const sourceRunway = getRunway?.(snapshot) || runway;
|
||||
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
|
||||
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
|
||||
const currentRemaining = sourceRunway.current_minutes === null ? null :
|
||||
Math.max(0, sourceRunway.current_minutes - currentElapsed);
|
||||
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
|
||||
currentRemaining + sourceRunway.future_minutes;
|
||||
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
|
||||
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
|
||||
sourceRunway.capacity_minutes - projected;
|
||||
return {
|
||||
...sourceRunway,
|
||||
remaining_minutes:remaining,
|
||||
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
|
||||
Math.max(0, currentElapsed - sourceRunway.current_minutes),
|
||||
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
|
||||
};
|
||||
})() : sourceRunway;
|
||||
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
|
||||
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
|
||||
const estimateRisk = liveRunway?.over_estimate_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
|
||||
const capacityRisk = liveRunway?.over_capacity_minutes ?
|
||||
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
|
||||
queryAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
|
||||
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button => {
|
||||
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
|
||||
});
|
||||
queryAll('[data-work-session-timer-toggle]').forEach(button => {
|
||||
button.hidden = !isActive();
|
||||
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
|
||||
button.setAttribute('aria-pressed', String(!snapshot.running));
|
||||
});
|
||||
};
|
||||
return {
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
let active = false;
|
||||
let resume = false;
|
||||
return {
|
||||
open() {
|
||||
if (active) return false;
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity) return false;
|
||||
resume = Boolean(state.running);
|
||||
if (resume && timer.pause() === false) return false;
|
||||
active = true;
|
||||
openPlan?.(state);
|
||||
return true;
|
||||
},
|
||||
restore() {
|
||||
if (!active) return false;
|
||||
active = false;
|
||||
const shouldResume = resume;
|
||||
resume = false;
|
||||
return !shouldResume || timer.resume() !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
|
||||
const render = pending => {
|
||||
if (!pending) {
|
||||
sheet.hidden = true;
|
||||
return false;
|
||||
}
|
||||
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
|
||||
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
|
||||
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
|
||||
sheet.hidden = false;
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
background() { return timer.markAway(); },
|
||||
foreground() { return render(timer.reconcileInterruption()); },
|
||||
restore() { return render(timer.pendingInterruption()); },
|
||||
resolve(decision) {
|
||||
if (!timer.resolveInterruption(decision)) return false;
|
||||
sheet.hidden = true;
|
||||
onResolved?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
const prefix = 'stackchain.today-work.v1.';
|
||||
const planningPrefix = 'stackchain.today-planning.v1.';
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function ownerKey(keyPrefix) {
|
||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||
return login ? keyPrefix + encodeURIComponent(login) : '';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return ownerKey(prefix);
|
||||
}
|
||||
|
||||
function planningKey() {
|
||||
return ownerKey(planningPrefix);
|
||||
}
|
||||
|
||||
function read() {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return [];
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(id => typeof id === 'string' && id) : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(ids) {
|
||||
const key = storageKey();
|
||||
if (!key || !storage) return false;
|
||||
try {
|
||||
if (ids.length) storage.setItem(key, JSON.stringify(ids));
|
||||
else storage.removeItem(key);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function planning() {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return { capacity_minutes: null, estimates: {} };
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || 'null');
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const ids = new Set(read());
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
return { capacity_minutes: capacity, estimates };
|
||||
} catch (_error) {
|
||||
return { capacity_minutes: null, estimates: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function replacePlanning(value) {
|
||||
const key = planningKey();
|
||||
if (!key || !storage) return false;
|
||||
const ids = new Set(read());
|
||||
const capacity = Number.isInteger(value?.capacity_minutes) && value.capacity_minutes > 0
|
||||
? value.capacity_minutes : null;
|
||||
const estimates = Object.fromEntries(Object.entries(value?.estimates || {}).filter(([id, minutes]) =>
|
||||
ids.has(id) && Number.isInteger(minutes) && minutes > 0
|
||||
));
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ capacity_minutes: capacity, estimates }));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function add(item) {
|
||||
if (!storageKey()) return 'unavailable';
|
||||
const id = identity(item);
|
||||
if (!id) return 'unavailable';
|
||||
const ids = read();
|
||||
if (ids.includes(id)) return 'exists';
|
||||
if (ids.length >= limit) return 'full';
|
||||
ids.push(id);
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
const saved = write(unique);
|
||||
if (saved && planningKey()) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function remove(item) {
|
||||
const id = identity(item);
|
||||
const ids = read();
|
||||
const next = ids.filter(candidate => candidate !== id);
|
||||
const saved = next.length !== ids.length && write(next);
|
||||
if (saved) replacePlanning(planning());
|
||||
return saved;
|
||||
}
|
||||
|
||||
function move(item, direction) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
const target = direction === 'up' ? index - 1 : direction === 'down' ? index + 1 : -1;
|
||||
if (index < 0 || target < 0 || target >= ids.length) return false;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
return write(ids);
|
||||
}
|
||||
|
||||
function reconcile(items, { pruneMissing = false, onPrune } = {}) {
|
||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||
const ids = read();
|
||||
const retained = pruneMissing ? ids.filter(id => available.has(id)) : ids;
|
||||
const retired = ids.filter(id => !retained.includes(id));
|
||||
if (retired.length) {
|
||||
const reported = onPrune?.(retired);
|
||||
if (reported !== false) {
|
||||
write(retained);
|
||||
replacePlanning(planning());
|
||||
}
|
||||
}
|
||||
return retained.flatMap(id => available.has(id) ? [available.get(id)] : []);
|
||||
}
|
||||
|
||||
function contains(item) {
|
||||
return read().includes(identity(item));
|
||||
}
|
||||
|
||||
function position(item) {
|
||||
const ids = read();
|
||||
const index = ids.indexOf(identity(item));
|
||||
return {
|
||||
can_up: index > 0,
|
||||
can_down: index >= 0 && index < ids.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
function runway(items, currentIndex = 0) {
|
||||
const plan = planning();
|
||||
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
|
||||
const total = values => values.some(value => value === null) ? null :
|
||||
values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: total(minutes),
|
||||
future_minutes: total(minutes.slice(1)),
|
||||
capacity_minutes: plan.capacity_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,152 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
now = () => Date.now(),
|
||||
maxItems = 20,
|
||||
}) {
|
||||
const storageKey = 'stackchain.unfiled-issues.v1';
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(storageKey) || 'null');
|
||||
if (record?.version !== 1 || !Array.isArray(record.items)) return [];
|
||||
return record.items.filter(item =>
|
||||
item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' &&
|
||||
typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string'
|
||||
);
|
||||
} catch (_error) { return []; }
|
||||
}
|
||||
|
||||
function write(items) {
|
||||
storage?.setItem(storageKey, JSON.stringify({version:1, items}));
|
||||
}
|
||||
|
||||
function list() {
|
||||
const currentLogin = String(getCurrentLogin() || '').trim();
|
||||
return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt))
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function capacity() {
|
||||
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const attachment = note?.attachment;
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read();
|
||||
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
|
||||
return persist(prepared, existing);
|
||||
}
|
||||
|
||||
function replaceOldest(note, expectedOldestId) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
const oldest = existing.at(-1);
|
||||
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
|
||||
throw new Error('Drafts changed. Review them before replacing anything.');
|
||||
}
|
||||
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
const item = read().find(candidate => candidate.id === id);
|
||||
if (!item) throw new Error('This capture is no longer available.');
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachment};
|
||||
});
|
||||
}
|
||||
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -0,0 +1,158 @@
|
||||
function createUpdateOwnership({
|
||||
claim, addToday, available, start, recover, onClaimed, onState, onStartState,
|
||||
}) {
|
||||
let detail = null;
|
||||
let update = null;
|
||||
let claimedItem = null;
|
||||
let request = null;
|
||||
|
||||
function state(value) {
|
||||
onState?.(value);
|
||||
}
|
||||
|
||||
function startState(value) {
|
||||
onStartState?.(value);
|
||||
}
|
||||
|
||||
function claimed(result) {
|
||||
claimedItem = {
|
||||
...result,
|
||||
repository: detail.repository,
|
||||
kind: 'issue',
|
||||
notification_id: update?.notification_id,
|
||||
updated_at: update?.updated_at || result.updated_at,
|
||||
has_update: true,
|
||||
};
|
||||
detail.issue.claimable = false;
|
||||
onClaimed?.(claimedItem);
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
return claimedItem;
|
||||
}
|
||||
|
||||
function recoverStartedItem(item) {
|
||||
recover?.(item);
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned to you, but Today could not start. The update is still unread; the owned issue is open so you can recover.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return 'recovery';
|
||||
}
|
||||
|
||||
function open(nextDetail, nextUpdate) {
|
||||
detail = nextDetail;
|
||||
update = nextUpdate;
|
||||
claimedItem = null;
|
||||
request = null;
|
||||
state({
|
||||
action: nextDetail?.issue?.claimable ? 'claim' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
startState({
|
||||
action: nextDetail?.issue?.claimable && available && start ? 'start' : 'hidden',
|
||||
busy: false,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
function act() {
|
||||
if (request) return request;
|
||||
if (claimedItem) {
|
||||
const result = addToday(claimedItem);
|
||||
const messages = {
|
||||
added: 'Added to Today.',
|
||||
exists: 'This issue is already in Today.',
|
||||
full: 'Today is limited to 5 items. Remove one before adding more.',
|
||||
unavailable: 'Could not save Today on this device.',
|
||||
};
|
||||
state({ action: 'today', busy: false, message: messages[result] || messages.unavailable });
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
if (!detail?.issue?.claimable) return Promise.resolve('hidden');
|
||||
|
||||
state({ action: 'claim', busy: true, message: 'Assigning…' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
claimed(result);
|
||||
state({ action: 'today', busy: false, message: 'Assigned to you. The update is still unread.' });
|
||||
return claimedItem;
|
||||
}).catch(error => {
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
startState({ action: 'hidden', busy: false, message: '' });
|
||||
state({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({
|
||||
action: 'claim',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
function startNow() {
|
||||
if (request) return request;
|
||||
if (!detail?.issue?.claimable || !available || !start) return Promise.resolve('hidden');
|
||||
if (!available()) {
|
||||
const message = 'Today is limited to 5 items. Remove one before taking ownership.';
|
||||
startState({ action: 'start', busy: false, message });
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
startState({ action: 'start', busy: true, message: 'Assigning and starting…' });
|
||||
state({ action: 'claim', busy: true, message: '' });
|
||||
request = Promise.resolve(claim({
|
||||
repository: detail.repository,
|
||||
number: detail.issue.number,
|
||||
})).then(result => {
|
||||
const item = claimed(result);
|
||||
const outcome = start(item);
|
||||
if (outcome === 'started') {
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Assigned, added to Today, and ready to work. The update is still unread.',
|
||||
});
|
||||
state({ action: 'today', busy: false, message: '' });
|
||||
return outcome;
|
||||
}
|
||||
return recoverStartedItem(item);
|
||||
}).catch(error => {
|
||||
if (claimedItem) {
|
||||
return recoverStartedItem(claimedItem);
|
||||
}
|
||||
if (error?.status === 409) {
|
||||
detail.issue.claimable = false;
|
||||
state({ action: 'hidden', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'hidden',
|
||||
busy: false,
|
||||
message: 'Someone else claimed or closed this issue. The update is still unread.',
|
||||
});
|
||||
return 'conflict';
|
||||
}
|
||||
state({ action: 'claim', busy: false, message: '' });
|
||||
startState({
|
||||
action: 'start',
|
||||
busy: false,
|
||||
message: (error?.message || 'The issue could not be assigned.') + ' The update and your reply draft are safe; retry.',
|
||||
});
|
||||
return 'retry';
|
||||
}).finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { open, act, start:startNow };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateOwnership;
|
||||
@@ -0,0 +1,64 @@
|
||||
function createUpdateReplyReadNext({
|
||||
post, markRead, queue, canQueue, deliver, accept = () => undefined, next = () => undefined,
|
||||
}) {
|
||||
let inFlight = null;
|
||||
|
||||
async function admit(item, body, operationId, replyConfirmed) {
|
||||
const admission = await queue({
|
||||
kind: 'update-reply-read',
|
||||
notificationId: item.notification_id,
|
||||
body,
|
||||
operationId,
|
||||
replyConfirmed,
|
||||
});
|
||||
if (!admission || (!admission.item && admission.durable !== true)) {
|
||||
throw new Error('Reply and acknowledgement were not saved for delivery.');
|
||||
}
|
||||
accept(item);
|
||||
return {
|
||||
accepted: true,
|
||||
delivery: admission.background ? 'queued' : 'saved',
|
||||
next: await next(item),
|
||||
};
|
||||
}
|
||||
|
||||
function submit(item, body, operationId, attachment = null) {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
let replyConfirmed = false;
|
||||
try {
|
||||
if (attachment) {
|
||||
const admission = await queue({
|
||||
kind:'update-reply-read', notificationId:item.notification_id, body,
|
||||
operationId, replyConfirmed:false, attachment,
|
||||
});
|
||||
if (!admission?.item) throw new Error('Reply and screenshot were not saved for delivery.');
|
||||
const outcome = deliver ? await deliver(admission.item) : null;
|
||||
const remaining = outcome?.remaining?.find(candidate => candidate.id === admission.item.id);
|
||||
if (remaining?.status === 'attention') {
|
||||
throw new Error(remaining.error || 'Reply and screenshot need attention.');
|
||||
}
|
||||
accept(item);
|
||||
return { accepted:true, delivery:outcome?.confirmed?.length ? 'posted' : 'queued', next:await next(item) };
|
||||
}
|
||||
try {
|
||||
await post(item, body, operationId);
|
||||
replyConfirmed = true;
|
||||
await markRead(item.notification_id);
|
||||
} catch (error) {
|
||||
if (!canQueue(error)) throw error;
|
||||
return await admit(item, body, operationId, replyConfirmed);
|
||||
}
|
||||
accept(item);
|
||||
return { accepted: true, delivery: 'posted', next: await next(item) };
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
return { submit, busy: () => Boolean(inFlight) };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateReplyReadNext;
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:1, login:owner, identities, current:value.current, completed };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function finish() {
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish();
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext: advance,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
+1
-14
@@ -5,19 +5,6 @@ function renderRepoMix(element, data) {
|
||||
element.innerHTML = '<div class="kv"><div class="label">Repos</div><div class="value">' + repos.length + '</div><div class="label">Open issues</div><div class="value">' + issues.length + '</div><div class="label">Open PRs</div><div class="value">' + pullRequests.length + '</div></div>';
|
||||
}
|
||||
|
||||
async function updateRepoMix(element, fetchContext = fetch) {
|
||||
try {
|
||||
const response = await fetchContext('api/v1/context', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
renderRepoMix(element, await response.json());
|
||||
} catch (error) {
|
||||
element.innerHTML = '<div class="muted">Widget unavailable.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
updateRepoMix.renderRepoMix = renderRepoMix;
|
||||
module.exports = updateRepoMix;
|
||||
module.exports = renderRepoMix;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createWorkRoute = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
|
||||
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
|
||||
|
||||
function positiveInteger(value) {
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
||||
}
|
||||
|
||||
function parse(fragment) {
|
||||
const parts = String(fragment || '').split('/');
|
||||
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
|
||||
const queue = parts[2]?.replace(/s$/, '');
|
||||
if (queueFilters.includes(queue) && parts.length === 3) {
|
||||
return { kind: 'queue', filter: queue };
|
||||
}
|
||||
if (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
|
||||
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
|
||||
}
|
||||
if (parts[2] === 'update' && parts.length === 4) {
|
||||
const notificationId = positiveInteger(parts[3]);
|
||||
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(parts[2]) || parts.length !== 6) return null;
|
||||
if (!repositoryPart.test(parts[3]) || !repositoryPart.test(parts[4])) return null;
|
||||
const number = positiveInteger(parts[5]);
|
||||
return number ? { kind: parts[2], repository: parts[3] + '/' + parts[4], number } : null;
|
||||
}
|
||||
|
||||
function serialize(item) {
|
||||
if (item?.kind === 'update') {
|
||||
const notificationId = positiveInteger(item.notification_id);
|
||||
return notificationId ? '#/my-work/update/' + notificationId : '';
|
||||
}
|
||||
if (!['issue', 'pull', 'review'].includes(item?.kind)) return '';
|
||||
const repository = String(item.repository || '').split('/');
|
||||
const number = positiveInteger(item.number);
|
||||
if (repository.length !== 2 || !repository.every(part => repositoryPart.test(part)) || !number) return '';
|
||||
return '#/my-work/' + item.kind + '/' + repository.join('/') + '/' + number;
|
||||
}
|
||||
|
||||
function sameRoute(item, route) {
|
||||
if (route.kind === 'queue') return false;
|
||||
if (route.kind === 'update') {
|
||||
return Number(item.notification_id) === route.notification_id;
|
||||
}
|
||||
const itemKind = item.is_review ? 'review' : item.kind;
|
||||
return itemKind === route.kind && item.repository === route.repository &&
|
||||
Number(item.number) === route.number;
|
||||
}
|
||||
|
||||
function createController({
|
||||
location, history, eventTarget, onOpen, onClose, onInvalid,
|
||||
onQueue = function () {},
|
||||
resolve, onResolving = function () {}, onError = function () {},
|
||||
}) {
|
||||
let items = [];
|
||||
let started = false;
|
||||
let active = '';
|
||||
let ready = false;
|
||||
let resolving = '';
|
||||
let resolution = 0;
|
||||
|
||||
function resolveMissing(fragment, route) {
|
||||
if (typeof resolve !== 'function') {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
if (resolving === fragment) return;
|
||||
resolving = fragment;
|
||||
const request = ++resolution;
|
||||
onResolving(route);
|
||||
Promise.resolve(resolve(route)).then(item => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (!item || !sameRoute(item, route)) {
|
||||
onInvalid();
|
||||
return;
|
||||
}
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}).catch(error => {
|
||||
if (request !== resolution || String(location.hash || '') !== fragment) return;
|
||||
resolving = '';
|
||||
if (error?.unavailable) onInvalid();
|
||||
else onError(error, route);
|
||||
});
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const fragment = String(location.hash || '');
|
||||
if (!fragment) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active) onClose();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
const route = parse(fragment);
|
||||
if (!route) {
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (fragment.startsWith('#/my-work/')) onInvalid();
|
||||
active = '';
|
||||
return;
|
||||
}
|
||||
if (route.kind === 'queue') {
|
||||
if (!ready) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
if (active && active !== fragment) onClose();
|
||||
if (active === fragment) return;
|
||||
active = fragment;
|
||||
onQueue(route.filter, route.action || null);
|
||||
return;
|
||||
}
|
||||
const item = items.find(candidate => sameRoute(candidate, route));
|
||||
if (!item) {
|
||||
if (ready) resolveMissing(fragment, route);
|
||||
return;
|
||||
}
|
||||
if (active === fragment) return;
|
||||
resolution += 1;
|
||||
resolving = '';
|
||||
active = fragment;
|
||||
onOpen({ ...item, kind: route.kind });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
eventTarget.addEventListener('popstate', sync);
|
||||
eventTarget.addEventListener('hashchange', sync);
|
||||
sync();
|
||||
},
|
||||
setItems(nextItems) {
|
||||
items = Array.isArray(nextItems) ? nextItems.slice() : [];
|
||||
ready = true;
|
||||
sync();
|
||||
},
|
||||
open(item, options = {}) {
|
||||
const fragment = serialize(item);
|
||||
if (!fragment) return false;
|
||||
const method = options.replace ? 'replaceState' : 'pushState';
|
||||
history[method]({ workRoute: fragment }, '', fragment);
|
||||
active = fragment;
|
||||
onOpen(item);
|
||||
return true;
|
||||
},
|
||||
queue(filter) {
|
||||
if (!queueFilters.includes(filter)) return false;
|
||||
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
|
||||
const fragment = '#/my-work/' + name;
|
||||
if (location.hash !== fragment) history.pushState(null, '', fragment);
|
||||
active = fragment;
|
||||
onQueue(filter);
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
if (parse(location.hash)) history.back();
|
||||
else {
|
||||
active = '';
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
async function share(url, navigatorObject, clipboard) {
|
||||
if (typeof navigatorObject?.share === 'function') {
|
||||
await navigatorObject.share({ url });
|
||||
return 'shared';
|
||||
}
|
||||
if (typeof clipboard?.writeText === 'function') {
|
||||
await clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
throw new Error('Sharing is unavailable.');
|
||||
}
|
||||
|
||||
return { parse, serialize, createController, share };
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function selectMany(items) {
|
||||
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
|
||||
let added = 0;
|
||||
let capped = false;
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id || selected.has(id)) continue;
|
||||
if (selected.size >= maximum) {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
selected.add(id);
|
||||
added += 1;
|
||||
}
|
||||
changed();
|
||||
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!active) return snapshot();
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, selectMany, clear, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"commit": "8ff742c0fe0970d43339b8931054d63b1029591d",
|
||||
"buildDate": "2026-07-12T12:56:54Z",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "./.gitea/workflows/ci.yml",
|
||||
"type": "file",
|
||||
"bytes": 662,
|
||||
"sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915"
|
||||
},\n {
|
||||
"path": "./.gitea/workflows/release.yml",
|
||||
"type": "file",
|
||||
"bytes": 884,
|
||||
"sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c"
|
||||
},\n {
|
||||
"path": "./README.md",
|
||||
"type": "file",
|
||||
"bytes": 94,
|
||||
"sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2"
|
||||
},\n {
|
||||
"path": "./creative-deliverables/widget-board-5.html",
|
||||
"type": "file",
|
||||
"bytes": 799,
|
||||
"sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a"
|
||||
},\n {
|
||||
"path": "./docs/ops-fundamentals.md",
|
||||
"type": "file",
|
||||
"bytes": 1123,
|
||||
"sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac"
|
||||
},\n {
|
||||
"path": "./frontend/index.html",
|
||||
"type": "file",
|
||||
"bytes": 19183,
|
||||
"sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a"
|
||||
},\n {
|
||||
"path": "./frontend/static/index.html",
|
||||
"type": "file",
|
||||
"bytes": 15834,
|
||||
"sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1"
|
||||
},\n {
|
||||
"path": "./scripts/gitea_sync.py",
|
||||
"type": "file",
|
||||
"bytes": 2987,
|
||||
"sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b"
|
||||
},\n {
|
||||
"path": "./src/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./src/gitea_proxy.py",
|
||||
"type": "file",
|
||||
"bytes": 914,
|
||||
"sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4"
|
||||
},\n {
|
||||
"path": "./src/main.py",
|
||||
"type": "file",
|
||||
"bytes": 2771,
|
||||
"sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7"
|
||||
},\n {
|
||||
"path": "./src/models.py",
|
||||
"type": "file",
|
||||
"bytes": 850,
|
||||
"sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a"
|
||||
},\n {
|
||||
"path": "./src/suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 1774,
|
||||
"sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8"
|
||||
},\n {
|
||||
"path": "./src/views.py",
|
||||
"type": "file",
|
||||
"bytes": 219,
|
||||
"sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63"
|
||||
},\n {
|
||||
"path": "./tests/__init__.py",
|
||||
"type": "file",
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},\n {
|
||||
"path": "./tests/test_suggestion_engine.py",
|
||||
"type": "file",
|
||||
"bytes": 810,
|
||||
"sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
fastapi==0.133.1
|
||||
httpx==0.28.1
|
||||
pydantic==2.13.4
|
||||
python-multipart==0.0.22
|
||||
pytest==9.1.1
|
||||
pywebpush==2.1.2
|
||||
rjsmin==1.2.5
|
||||
uvicorn==0.41.0
|
||||
webauthn==3.0.0
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a reproducible, verifiable Stackchain Dashboard release bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
|
||||
RUNTIME_FILES = ("README.md", "requirements.txt")
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def runtime_files(root: Path) -> list[Path]:
|
||||
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
|
||||
for name in RUNTIME_DIRECTORIES:
|
||||
directory = root / name
|
||||
if directory.is_dir():
|
||||
files.extend(path for path in directory.rglob("*") if path.is_file())
|
||||
return sorted(files, key=lambda path: path.relative_to(root).as_posix())
|
||||
|
||||
|
||||
def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": {
|
||||
path.relative_to(root).as_posix(): {
|
||||
"sha256": sha256(path.read_bytes()),
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
for path in files
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def json_bytes(payload: dict) -> bytes:
|
||||
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode()
|
||||
|
||||
|
||||
def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = size
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if executable else 0o644
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
return info
|
||||
|
||||
|
||||
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
|
||||
root = root.resolve()
|
||||
files = runtime_files(root)
|
||||
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
|
||||
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
|
||||
raise ValueError("release root is missing required runtime files")
|
||||
|
||||
embedded_manifest = manifest_for(root, files, commit)
|
||||
archive_members: dict[str, tuple[bytes, bool]] = {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.read_bytes(),
|
||||
bool(path.stat().st_mode & 0o111),
|
||||
)
|
||||
for path in files
|
||||
}
|
||||
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"stackchain-dashboard-{commit[:12]}"
|
||||
archive_path = output_dir / f"{stem}.tar.gz"
|
||||
with archive_path.open("wb") as raw:
|
||||
with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed:
|
||||
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for name in sorted(archive_members):
|
||||
data, executable = archive_members[name]
|
||||
archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data))
|
||||
|
||||
archive_data = archive_path.read_bytes()
|
||||
digest = sha256(archive_data)
|
||||
public_manifest = {
|
||||
**embedded_manifest,
|
||||
"artifact": {
|
||||
"name": archive_path.name,
|
||||
"sha256": digest,
|
||||
"size": len(archive_data),
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / f"{stem}.manifest.json"
|
||||
checksum_path = output_dir / f"{stem}.sha256"
|
||||
manifest_path.write_bytes(json_bytes(public_manifest))
|
||||
checksum_path.write_text(f"{digest} {archive_path.name}\n")
|
||||
return archive_path, manifest_path, checksum_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=Path.cwd())
|
||||
parser.add_argument("--output-dir", type=Path, default=Path("dist"))
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--source-date-epoch", type=int, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a Stackchain Dashboard release bundle before promotion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
def one(directory: Path, pattern: str) -> Path:
|
||||
matches = sorted(directory.glob(pattern))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def digest(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def verify(input_dir: Path, commit: str) -> None:
|
||||
archive_path = one(input_dir, "*.tar.gz")
|
||||
manifest_path = one(input_dir, "*.manifest.json")
|
||||
checksum_path = one(input_dir, "*.sha256")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
|
||||
if manifest.get("commit") != commit:
|
||||
raise ValueError("manifest commit does not match the tested commit")
|
||||
artifact = manifest.get("artifact", {})
|
||||
archive_data = archive_path.read_bytes()
|
||||
archive_digest = digest(archive_data)
|
||||
if artifact.get("name") != archive_path.name:
|
||||
raise ValueError("manifest artifact name does not match the bundle")
|
||||
if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest:
|
||||
raise ValueError("bundle does not match its manifest")
|
||||
|
||||
checksum_parts = checksum_path.read_text().strip().split()
|
||||
if checksum_parts != [archive_digest, archive_path.name]:
|
||||
raise ValueError("checksum file does not match the bundle")
|
||||
|
||||
expected_files = manifest.get("files")
|
||||
if not isinstance(expected_files, dict):
|
||||
raise ValueError("manifest files must be an object")
|
||||
with tarfile.open(archive_path, "r:gz") as archive:
|
||||
members = archive.getmembers()
|
||||
for member in members:
|
||||
path = PurePosixPath(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or not member.isfile():
|
||||
raise ValueError("bundle contains an unsafe member")
|
||||
names = {member.name for member in members}
|
||||
if names != set(expected_files) | {"release-manifest.json"}:
|
||||
raise ValueError("bundle contents do not match the manifest")
|
||||
embedded_file = archive.extractfile("release-manifest.json")
|
||||
if embedded_file is None:
|
||||
raise ValueError("bundle manifest is missing")
|
||||
embedded = json.load(embedded_file)
|
||||
if embedded.get("commit") != commit or embedded.get("files") != expected_files:
|
||||
raise ValueError("embedded manifest does not match the public manifest")
|
||||
for name, metadata in expected_files.items():
|
||||
bundled = archive.extractfile(name)
|
||||
if bundled is None:
|
||||
raise ValueError(f"bundle member is missing: {name}")
|
||||
data = bundled.read()
|
||||
if metadata != {"sha256": digest(data), "size": len(data)}:
|
||||
raise ValueError(f"bundle member failed verification: {name}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input-dir", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
try:
|
||||
verify(args.input_dir, args.commit)
|
||||
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
|
||||
print(f"release verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
print(f"verified release bundle for {args.commit}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Worker-shared Find Work catalog state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RefreshLeaseLost(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AvailableIssueSnapshotState:
|
||||
items: list[dict] | None
|
||||
created_at: float | None
|
||||
retry_at: float | None
|
||||
refreshing: bool
|
||||
lease_expires_at: float | None
|
||||
|
||||
|
||||
class AvailableIssueSnapshotStore:
|
||||
def __init__(self, path, *, clock=None):
|
||||
self.path = Path(path)
|
||||
self.clock = clock or time.time
|
||||
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(self.path.parent, stat.S_IRWXU)
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS available_issue_snapshot (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
items_json TEXT,
|
||||
created_at REAL,
|
||||
retry_at REAL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_refresh_lease (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
owner TEXT NOT NULL,
|
||||
expires_at REAL NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS available_issue_claim (
|
||||
repository TEXT NOT NULL,
|
||||
number INTEGER NOT NULL,
|
||||
PRIMARY KEY (repository, number)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_snapshot VALUES (1, NULL, NULL, NULL)"
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.path, timeout=1.0, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA busy_timeout = 1000")
|
||||
return connection
|
||||
|
||||
def try_acquire_refresh(self, *, lease_seconds: float) -> str | None:
|
||||
if lease_seconds <= 0:
|
||||
raise ValueError("refresh lease duration must be positive")
|
||||
now = self.clock()
|
||||
owner = secrets.token_hex(16)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
active = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if active is not None and active["expires_at"] > now:
|
||||
connection.rollback()
|
||||
return None
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.execute(
|
||||
"INSERT INTO available_issue_refresh_lease VALUES (1, ?, ?)",
|
||||
(owner, now + lease_seconds),
|
||||
)
|
||||
connection.commit()
|
||||
return owner
|
||||
|
||||
def load(self) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
lease = connection.execute(
|
||||
"SELECT expires_at FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND expires_at > ?", (now,)
|
||||
).fetchone()
|
||||
return AvailableIssueSnapshotState(
|
||||
items=json.loads(row["items_json"]) if row["items_json"] else None,
|
||||
created_at=row["created_at"],
|
||||
retry_at=row["retry_at"],
|
||||
refreshing=lease is not None,
|
||||
lease_expires_at=lease["expires_at"] if lease else None,
|
||||
)
|
||||
|
||||
def publish(self, owner: str | None, *, items: list[dict]) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
claimed = {
|
||||
(row["repository"], row["number"])
|
||||
for row in connection.execute(
|
||||
"SELECT repository, number FROM available_issue_claim"
|
||||
)
|
||||
}
|
||||
items = [
|
||||
item for item in items
|
||||
if (item.get("repository"), item.get("number")) not in claimed
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ?, created_at = ?, "
|
||||
"retry_at = NULL WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")), now),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def remove_claimed(self, repository: str, number: int) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO available_issue_claim VALUES (?, ?)",
|
||||
(repository, number),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT items_json FROM available_issue_snapshot WHERE singleton = 1"
|
||||
).fetchone()
|
||||
items = json.loads(row["items_json"]) if row["items_json"] else None
|
||||
if items is not None:
|
||||
items = [
|
||||
item for item in items
|
||||
if item.get("repository") != repository or item.get("number") != number
|
||||
]
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = ? WHERE singleton = 1",
|
||||
(json.dumps(items, separators=(",", ":")),),
|
||||
)
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def invalidate(self) -> AvailableIssueSnapshotState:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET items_json = NULL, "
|
||||
"created_at = NULL, retry_at = NULL WHERE singleton = 1"
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_claim")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
|
||||
def release_refresh(self, owner: str | None) -> None:
|
||||
if owner is None:
|
||||
return
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM available_issue_refresh_lease "
|
||||
"WHERE singleton = 1 AND owner = ?",
|
||||
(owner,),
|
||||
)
|
||||
|
||||
def fail_refresh(self, owner: str | None, *, retry_at: float) -> AvailableIssueSnapshotState:
|
||||
now = self.clock()
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
lease = connection.execute(
|
||||
"SELECT owner, expires_at FROM available_issue_refresh_lease WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if (
|
||||
owner is None
|
||||
or lease is None
|
||||
or lease["owner"] != owner
|
||||
or lease["expires_at"] <= now
|
||||
):
|
||||
connection.rollback()
|
||||
raise RefreshLeaseLost("available issue refresh lease expired or changed owner")
|
||||
connection.execute(
|
||||
"UPDATE available_issue_snapshot SET retry_at = ? WHERE singleton = 1",
|
||||
(retry_at,),
|
||||
)
|
||||
connection.execute("DELETE FROM available_issue_refresh_lease WHERE singleton = 1")
|
||||
connection.commit()
|
||||
return self.load()
|
||||
@@ -0,0 +1,47 @@
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.gzip import GZipResponder, IdentityResponder
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
def _quality(accept_encoding: str, coding: str) -> float:
|
||||
qualities: dict[str, float] = {}
|
||||
for entry in accept_encoding.split(","):
|
||||
parts = [part.strip() for part in entry.split(";")]
|
||||
name = parts[0].lower()
|
||||
if not name:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
if separator and key.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
qualities[name] = quality
|
||||
return qualities.get(coding, qualities.get("*", 0.0))
|
||||
|
||||
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 6):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
path = scope.get("path", "").rsplit("/", 1)[-1]
|
||||
immutable_runtime = path.startswith("runtime-") and path.endswith(".js")
|
||||
if _quality(accepted, "gzip") > 0 and not immutable_runtime:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
else:
|
||||
responder = IdentityResponder(self.app, self.minimum_size)
|
||||
await responder(scope, receive, send)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Signed, short-lived single-operator sessions for the dashboard boundary."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from src.session_store import SessionStore, SessionStoreError
|
||||
|
||||
SESSION_COOKIE = "stackchain_session"
|
||||
CSRF_COOKIE = "stackchain_csrf"
|
||||
DEFAULT_TTL_SECONDS = 8 * 60 * 60
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||
STEP_UP_TTL_SECONDS = 90
|
||||
MIN_SECRET_LENGTH = 24
|
||||
OPERATOR_MODE = "operator"
|
||||
INSECURE_LOCAL_MODE = "insecure-local"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Session:
|
||||
session_id: str
|
||||
csrf: str
|
||||
expires_at: int
|
||||
idle_expires_at: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionVerification:
|
||||
session: Session | None
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def access_token() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "")
|
||||
|
||||
|
||||
def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def public_origin() -> str | None:
|
||||
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(configured)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
hostname = parsed.hostname.lower()
|
||||
authority = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if port is not None and port != 443:
|
||||
authority += f":{port}"
|
||||
return f"https://{authority}"
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
return None
|
||||
if configured_mode != OPERATOR_MODE:
|
||||
return "unsupported authentication mode"
|
||||
operator_secret = access_token()
|
||||
signing_secret = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if len(operator_secret) < MIN_SECRET_LENGTH or len(signing_secret) < MIN_SECRET_LENGTH:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
if public_origin() is None:
|
||||
return "operator mode requires a canonical HTTPS public origin"
|
||||
return None
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return mode() == OPERATOR_MODE and configuration_error() is None
|
||||
|
||||
|
||||
def is_loopback_request(request: Request) -> bool:
|
||||
host = request.client.host if request.client else ""
|
||||
try:
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return host == "localhost"
|
||||
|
||||
|
||||
def _secret() -> bytes:
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "")
|
||||
if configuration_error() is not None or mode() != OPERATOR_MODE:
|
||||
raise RuntimeError("Dashboard authentication is not configured")
|
||||
return configured.encode()
|
||||
|
||||
|
||||
def _encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def _session_store(now: int | None = None) -> SessionStore:
|
||||
database = os.getenv(
|
||||
"STACKCHAIN_SESSION_DB",
|
||||
os.path.join(os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state"), "sessions.sqlite3"),
|
||||
)
|
||||
current = time.time if now is None else lambda: now
|
||||
return SessionStore(database, clock=current)
|
||||
|
||||
|
||||
def idle_timeout_seconds() -> int:
|
||||
return max(
|
||||
1,
|
||||
int(
|
||||
os.getenv(
|
||||
"STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS",
|
||||
str(DEFAULT_IDLE_TIMEOUT_SECONDS),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def issue_session(
|
||||
now: int | None = None,
|
||||
*,
|
||||
device_label: str = "This device",
|
||||
management_id: str | None = None,
|
||||
) -> tuple[str, Session]:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||
session = Session(
|
||||
session_id=secrets.token_urlsafe(32),
|
||||
csrf=secrets.token_urlsafe(24),
|
||||
expires_at=issued_at + max(1, ttl),
|
||||
)
|
||||
payload = json.dumps(
|
||||
{"csrf": session.csrf, "exp": session.expires_at, "sid": session.session_id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
encoded = _encode(payload)
|
||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
_session_store(now).activate(
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
device_label=device_label,
|
||||
management_id=management_id,
|
||||
)
|
||||
return f"{encoded}.{signature}", session
|
||||
|
||||
|
||||
def verify_session_with_reason(
|
||||
value: str | None, now: int | None = None
|
||||
) -> SessionVerification:
|
||||
if not value or "." not in value or not enabled():
|
||||
return SessionVerification(None)
|
||||
encoded, supplied_signature = value.rsplit(".", 1)
|
||||
expected = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||
if not hmac.compare_digest(supplied_signature, expected):
|
||||
return SessionVerification(None)
|
||||
try:
|
||||
payload = json.loads(_decode(encoded))
|
||||
session = Session(
|
||||
session_id=payload["sid"],
|
||||
csrf=payload["csrf"],
|
||||
expires_at=int(payload["exp"]),
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return SessionVerification(None)
|
||||
current = int(time.time() if now is None else now)
|
||||
if (
|
||||
session.expires_at <= current
|
||||
or not isinstance(session.session_id, str)
|
||||
or not session.session_id
|
||||
or not isinstance(session.csrf, str)
|
||||
or not session.csrf
|
||||
):
|
||||
return SessionVerification(None)
|
||||
status = _session_store(now).status(
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
if status == "idle":
|
||||
return SessionVerification(None, "session_idle")
|
||||
if status != "active":
|
||||
return SessionVerification(None, "session_revoked")
|
||||
return SessionVerification(
|
||||
Session(
|
||||
session_id=session.session_id,
|
||||
csrf=session.csrf,
|
||||
expires_at=session.expires_at,
|
||||
idle_expires_at=getattr(status, "idle_expires_at", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def verify_session(value: str | None, now: int | None = None) -> Session | None:
|
||||
return verify_session_with_reason(value, now).session
|
||||
|
||||
|
||||
def revoke_session(session: Session) -> None:
|
||||
_session_store().revoke(session.session_id)
|
||||
|
||||
|
||||
async def revoke_all_sessions() -> None:
|
||||
await asyncio.to_thread(_session_store().revoke_all)
|
||||
|
||||
|
||||
async def active_devices(session: Session):
|
||||
return await asyncio.to_thread(_session_store().list_active, session.session_id)
|
||||
|
||||
|
||||
async def session_management_id(session: Session) -> str:
|
||||
return await asyncio.to_thread(_session_store().management_id, session.session_id)
|
||||
|
||||
|
||||
async def managed_session_active(management_id: str) -> bool:
|
||||
status = await asyncio.to_thread(
|
||||
_session_store().managed_status,
|
||||
management_id,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
return status == "active"
|
||||
|
||||
|
||||
async def managed_session_statuses(management_ids) -> dict[str, str]:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().managed_statuses,
|
||||
management_ids,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def touch_session(session: Session) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().touch,
|
||||
session.session_id,
|
||||
session.expires_at,
|
||||
idle_timeout_seconds=idle_timeout_seconds(),
|
||||
)
|
||||
|
||||
|
||||
async def revoke_managed_session(management_id: str) -> bool:
|
||||
return await asyncio.to_thread(_session_store().revoke_managed, management_id)
|
||||
|
||||
|
||||
async def issue_step_up(session: Session, *, action: str, target: str) -> str:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().mint_step_up,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
ttl_seconds=STEP_UP_TTL_SECONDS,
|
||||
)
|
||||
|
||||
|
||||
async def consume_step_up(
|
||||
grant: str, session: Session, *, action: str, target: str
|
||||
) -> bool:
|
||||
return await asyncio.to_thread(
|
||||
_session_store().consume_step_up,
|
||||
grant,
|
||||
session.session_id,
|
||||
action=action,
|
||||
target=target,
|
||||
)
|
||||
|
||||
|
||||
async def request_session(request: Request) -> Session | None:
|
||||
return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
async def request_session_verification(request: Request) -> SessionVerification:
|
||||
return await asyncio.to_thread(
|
||||
verify_session_with_reason, request.cookies.get(SESSION_COOKIE)
|
||||
)
|
||||
|
||||
|
||||
def cookie_path(request: Request) -> str:
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
return root_path or "/"
|
||||
|
||||
|
||||
def application_path(request: Request) -> str:
|
||||
path = request.url.path
|
||||
root_path = request.scope.get("root_path", "").rstrip("/")
|
||||
if root_path and (path == root_path or path.startswith(root_path + "/")):
|
||||
return path[len(root_path):] or "/"
|
||||
return path
|
||||
|
||||
|
||||
def same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin", "")
|
||||
expected = public_origin()
|
||||
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Build the content-addressed browser shell served by the dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||
COMMONJS_EXPORT_LINE = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
|
||||
)
|
||||
COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
|
||||
re.MULTILINE,
|
||||
)
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeBundle:
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontendBuild:
|
||||
dashboard_html: str
|
||||
runtime_bytes: bytes
|
||||
runtime_gzip_bytes: bytes
|
||||
runtime_digest: str
|
||||
runtime_name: str
|
||||
shell_digest: str
|
||||
page_sources: tuple[str, ...]
|
||||
feature_bundles: dict[str, RuntimeBundle]
|
||||
service_worker_source: str
|
||||
|
||||
|
||||
def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
|
||||
chunks = []
|
||||
for source in sources:
|
||||
path = frontend_dir / source.removeprefix("static/")
|
||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
||||
source = b"".join(chunks)
|
||||
# Node-only export shims support source-level unit tests but are unreachable
|
||||
# in the browser. Strip the simple one-line form from shipped bundles.
|
||||
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
|
||||
source = COMMONJS_EXPORT_LINE.sub(b"", source)
|
||||
revision = hashlib.sha256(source).hexdigest()
|
||||
minified = rjsmin.jsmin(source.decode()).encode()
|
||||
return minified + f';"source-sha256:{revision}";'.encode()
|
||||
|
||||
|
||||
def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> RuntimeBundle:
|
||||
content = _bundle(frontend_dir, sources)
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
|
||||
def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
"""Return one internally consistent HTML, runtime, and offline-worker build."""
|
||||
source_html = (frontend_dir / "index.html").read_text()
|
||||
sources = tuple(SCRIPT_TAG.findall(source_html))
|
||||
if not sources:
|
||||
raise ValueError("dashboard entry document has no page scripts")
|
||||
|
||||
feature_source_names = {source for group in FEATURE_SOURCES.values() for source in group}
|
||||
core_sources = tuple(source for source in sources if source not in feature_source_names)
|
||||
core = _runtime(frontend_dir, core_sources, "runtime")
|
||||
feature_bundles = {
|
||||
name: _runtime(frontend_dir, feature_sources, f"feature-{name}")
|
||||
for name, feature_sources in FEATURE_SOURCES.items()
|
||||
}
|
||||
dashboard_html = SCRIPT_TAG.sub("", source_html)
|
||||
feature_metadata = "\n".join(
|
||||
f'<meta name="stackchain-feature-{name}" content="{bundle.runtime_name}">'
|
||||
for name, bundle in feature_bundles.items()
|
||||
)
|
||||
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
|
||||
dashboard_html = dashboard_html.replace(
|
||||
"</body>",
|
||||
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
f'<script src="{core.runtime_name}"></script>\n</body>',
|
||||
)
|
||||
|
||||
worker = (frontend_dir / "service-worker.js").read_text()
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
)
|
||||
|
||||
shell_hasher = hashlib.sha256()
|
||||
shell_inputs = (
|
||||
("dashboard.html", dashboard_html.encode()),
|
||||
(core.runtime_name, core.runtime_bytes),
|
||||
*((bundle.runtime_name, bundle.runtime_bytes) for bundle in feature_bundles.values()),
|
||||
("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()),
|
||||
("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()),
|
||||
(
|
||||
"static/icons/stackchain-192.png",
|
||||
(frontend_dir / "icons/stackchain-192.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
"static/icons/stackchain-512.png",
|
||||
(frontend_dir / "icons/stackchain-512.png").read_bytes(),
|
||||
),
|
||||
(
|
||||
WORKER_RUNTIME_SOURCE,
|
||||
(
|
||||
frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/")
|
||||
).read_bytes(),
|
||||
),
|
||||
("service-worker.js", worker.encode()),
|
||||
)
|
||||
for name, content in shell_inputs:
|
||||
shell_hasher.update(len(name).to_bytes(4, "big"))
|
||||
shell_hasher.update(name.encode())
|
||||
shell_hasher.update(len(content).to_bytes(8, "big"))
|
||||
shell_hasher.update(content)
|
||||
shell_digest = shell_hasher.hexdigest()[:16]
|
||||
worker = worker.replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';",
|
||||
f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';",
|
||||
1,
|
||||
)
|
||||
|
||||
return FrontendBuild(
|
||||
dashboard_html=dashboard_html,
|
||||
runtime_bytes=core.runtime_bytes,
|
||||
runtime_gzip_bytes=core.runtime_gzip_bytes,
|
||||
runtime_digest=core.runtime_digest,
|
||||
runtime_name=core.runtime_name,
|
||||
shell_digest=shell_digest,
|
||||
page_sources=sources,
|
||||
feature_bundles=feature_bundles,
|
||||
service_worker_source=worker,
|
||||
)
|
||||
+2374
-48
File diff suppressed because it is too large.
Load diff
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user