mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
Compare commits
705
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c3b6a7b2 | ||
|
|
d1e88ab4b5 | ||
|
|
dc57c63228 | ||
|
|
9694ef2512 | ||
|
|
20c5136a40 | ||
|
|
76625c6ac5 | ||
|
|
7ae993898b | ||
|
|
b1bdf20af4 | ||
|
|
5c43ccaac5 | ||
|
|
41a5a9f59e | ||
|
|
127cf84e1b | ||
|
|
5b6fbebe90 | ||
|
|
1e46083f2b | ||
|
|
0fe5b902e9 | ||
|
|
065171bc18 | ||
|
|
0b57de1de5 | ||
|
|
4586dcd44d | ||
|
|
a438fdf21f | ||
|
|
5e52b722cd | ||
|
|
04ba5f01e5 | ||
|
|
eb553eb2e5 | ||
|
|
884ef6ba17 | ||
|
|
65a3e24aed | ||
|
|
7fdf162838 | ||
|
|
fd3509bbd6 | ||
|
|
48214aea9d | ||
|
|
050757bcaa | ||
|
|
b81f18e4f5 | ||
|
|
e327f5f6de | ||
|
|
b3e9c5c20c | ||
|
|
70f1834655 | ||
|
|
e42f3d7307 | ||
|
|
a18bae6672 | ||
|
|
4d5faa7406 | ||
|
|
c7c266c309 | ||
|
|
105da7dfea | ||
|
|
5a183a707c | ||
|
|
d51b92998c | ||
|
|
45c0ccadcd | ||
|
|
d345c25f71 | ||
|
|
4fd9bfecc6 | ||
|
|
4952707bbb | ||
|
|
bf9bb3fc47 | ||
|
|
6c955135d9 | ||
|
|
f77ddbce35 | ||
|
|
f3b163a29e | ||
|
|
9944e1e04c | ||
|
|
e08b81e2ce | ||
|
|
eea80fc31a | ||
|
|
6a2caa5083 | ||
|
|
b9dc55586b | ||
|
|
87e3258b09 | ||
|
|
3421febc32 | ||
|
|
175c79f2ee | ||
|
|
0ff3461815 | ||
|
|
cb4406c715 | ||
|
|
23d25e58fa | ||
|
|
916539787e | ||
|
|
cad6ccb0cd | ||
|
|
8ac2711668 | ||
|
|
8e57d6f979 | ||
|
|
42890875b9 | ||
|
|
d6cc18b2a9 | ||
|
|
7b40a6ffca | ||
|
|
063801e1af | ||
|
|
890326bdb0 | ||
|
|
daf41df76d | ||
|
|
87ff3f4256 | ||
|
|
b10680698f | ||
|
|
6e7b62b58e | ||
|
|
145003646c | ||
|
|
b67eaa58a0 | ||
|
|
96dc6c8779 | ||
|
|
19a5a3ad5a | ||
|
|
d470f4f660 | ||
|
|
0bad902bbd | ||
|
|
5e8c754f41 | ||
|
|
6d3a09932a | ||
|
|
c511c90a7c | ||
|
|
6e3afd19fd | ||
|
|
ed8852f62a | ||
|
|
d78eaaaaf5 | ||
|
|
16755c7d6b | ||
|
|
6e04b6a665 | ||
|
|
d93a202c35 | ||
|
|
de0fbc681a | ||
|
|
a7fb7aeff4 | ||
|
|
8b4d1fc930 | ||
|
|
76688650b4 | ||
|
|
d99e5d8b26 | ||
|
|
bccd7c920d | ||
|
|
de00ff46e4 | ||
|
|
95eb27dab1 | ||
|
|
3ebaf047e0 | ||
|
|
6c3769bb9b | ||
|
|
64ce119aa4 | ||
|
|
387d215c81 | ||
|
|
16810e0b4d | ||
|
|
cc2c07b4bc | ||
|
|
f04ceabac6 | ||
|
|
a913d2dd83 | ||
|
|
540677b382 | ||
|
|
3c5c6a2537 | ||
|
|
b3a6159b40 | ||
|
|
a018b417e3 | ||
|
|
a93ea17aa4 | ||
|
|
00cbb87c5d | ||
|
|
8a31da122c | ||
|
|
b7cddbb253 | ||
|
|
50f4ec9f6b | ||
|
|
7068ffae92 | ||
|
|
f4c6804398 | ||
|
|
0975e1c1bc | ||
|
|
7abdbf3eb0 | ||
|
|
268829afb2 | ||
|
|
63df21a9ce | ||
|
|
6627e8e594 | ||
|
|
9174c6e265 | ||
|
|
0f745f73d0 | ||
|
|
a504853149 | ||
|
|
8c45af6bdd | ||
|
|
6285f37915 | ||
|
|
5fb7a54e59 | ||
|
|
8f22a9f8cd | ||
|
|
edf56588a2 | ||
|
|
d662c6a39e | ||
|
|
ab92129752 | ||
|
|
e2918951bb | ||
|
|
44905e021b | ||
|
|
1a88b640f6 | ||
|
|
ead985d0b9 | ||
|
|
34429cf8d6 | ||
|
|
89eabbfb5f | ||
|
|
05e0539a1e | ||
|
|
d12bafae30 | ||
|
|
dee61595f6 | ||
|
|
c7004df282 | ||
|
|
bd700c8b67 | ||
|
|
d8943706c0 | ||
|
|
99eca47cc3 | ||
|
|
abe260010c | ||
|
|
f382082c76 | ||
|
|
39df0f53d1 | ||
|
|
5fd7467b7c | ||
|
|
adbe7e7ab1 | ||
|
|
a10bd55a73 | ||
|
|
25539c6e24 | ||
|
|
1065bf89ac | ||
|
|
c86abb1ad4 | ||
|
|
35d06151fa | ||
|
|
f0d14ebfa6 | ||
|
|
9250ef2dbc | ||
|
|
f3872d9c84 | ||
|
|
68cfe5b679 | ||
|
|
538b8183cd | ||
|
|
cbdf5c9cdc | ||
|
|
8103a24954 | ||
|
|
1ea8d808b4 | ||
|
|
4d59f4a20c | ||
|
|
614fcc3c4d | ||
|
|
a83902d7e2 | ||
|
|
a7d6b80e2c | ||
|
|
854077dee8 | ||
|
|
8379680404 | ||
|
|
cb05362a85 | ||
|
|
03204b15fe | ||
|
|
2b1842ecac | ||
|
|
5cecc55613 | ||
|
|
c8a16708ca | ||
|
|
ff6c68c3e9 | ||
|
|
042b02d953 | ||
|
|
f8734ce1a1 | ||
|
|
1c141a9c78 | ||
|
|
3015313d23 | ||
|
|
4e449037e6 | ||
|
|
46280b7e87 | ||
|
|
4722a49ab9 | ||
|
|
5db11475a5 | ||
|
|
e3af7bc3a3 | ||
|
|
13fd1003f1 | ||
|
|
741b10b435 | ||
|
|
9511ae838a | ||
|
|
01d95dcdee | ||
|
|
ca6d0c22e7 | ||
|
|
78086e63e4 | ||
|
|
4fcb0a120a | ||
|
|
f35d2a9997 | ||
|
|
49f3ded4dc | ||
|
|
4d59319d17 | ||
|
|
bcb2f7a624 | ||
|
|
ce113aabf1 | ||
|
|
13e1823617 | ||
|
|
8ec7667322 | ||
|
|
8dfe536100 | ||
|
|
a51013073f | ||
|
|
6267839596 | ||
|
|
e029e1a1c6 | ||
|
|
a3c282bb49 | ||
|
|
f441d781ec | ||
|
|
3de078190e | ||
|
|
8334249d5d | ||
|
|
a85f6528f8 | ||
|
|
2c9789fafc | ||
|
|
c2b7e427af | ||
|
|
fd79509106 | ||
|
|
c345d1694a | ||
|
|
5136322a74 | ||
|
|
2ea94eaad0 | ||
|
|
7510e91d9a | ||
|
|
d9f57be531 | ||
|
|
58671d9cc5 | ||
|
|
0264cfbb03 | ||
|
|
668bf6f02c | ||
|
|
166a0ba226 | ||
|
|
0204dff904 | ||
|
|
7e683635df | ||
|
|
948f979869 | ||
|
|
4e6c9b703d | ||
|
|
06bafe46b5 | ||
|
|
643f621241 | ||
|
|
13b6acafed | ||
|
|
a8bae14443 | ||
|
|
b58ae49e46 | ||
|
|
e302e2003b | ||
|
|
d60ea853fa | ||
|
|
d2841a5029 | ||
|
|
6565b1b94f | ||
|
|
c9d93b6a4e | ||
|
|
aae04246c4 | ||
|
|
8be7a5b131 | ||
|
|
4c4341db96 | ||
|
|
b950ff8f2e | ||
|
|
16a3806ace | ||
|
|
92e6ad0b5e | ||
|
|
0f31541fa8 | ||
|
|
61d3c5b3f6 | ||
|
|
583fb28a42 | ||
|
|
5f7b941e5a | ||
|
|
1e67df5566 | ||
|
|
326ee4f96d | ||
|
|
4e618f3454 | ||
|
|
3ef9639dbf | ||
|
|
c915ec76c6 | ||
|
|
b79b79d6d7 | ||
|
|
b4c011e553 | ||
|
|
652c72b9e2 | ||
|
|
15d02fa1c1 | ||
|
|
50f5d1698d | ||
|
|
f6c49e3183 | ||
|
|
ae798d5862 | ||
|
|
12dfcb424b | ||
|
|
4a5d1c78c1 | ||
|
|
3a3f4199a1 | ||
|
|
318c8221d1 | ||
|
|
0eeebba43e | ||
|
|
e8d1f4cea2 | ||
|
|
d0447bc264 | ||
|
|
fc5fe07d25 | ||
|
|
a313b99590 | ||
|
|
9d38961a61 | ||
|
|
c0c4a470ab | ||
|
|
d51926a7e3 | ||
|
|
74b825f109 | ||
|
|
041eb78f4b | ||
|
|
9a525d6c9c | ||
|
|
ba57a3cb4c | ||
|
|
3668b0a481 | ||
|
|
160e57bbf4 | ||
|
|
846081d588 | ||
|
|
d5e34a3eea | ||
|
|
ba4496319a | ||
|
|
ddea4a44d4 | ||
|
|
e08a59537b | ||
|
|
b7553e6489 | ||
|
|
3af7e2c55d | ||
|
|
5d341d3afd | ||
|
|
7815319a7a | ||
|
|
ad6dd5d9d5 | ||
|
|
7e6dc57e17 | ||
|
|
b07f043e7e | ||
|
|
da9040ede5 | ||
|
|
3def78fc5a | ||
|
|
40c51b605c | ||
|
|
87aa9f4d29 | ||
|
|
b0f36c88a1 | ||
|
|
32b12cf708 | ||
|
|
9752940fba | ||
|
|
131db14d62 | ||
|
|
ab1cbf1a0a | ||
|
|
e64a7716fe | ||
|
|
adf1bb0be7 | ||
|
|
ddb8e1ad7f | ||
|
|
12139b55df | ||
|
|
c63a177683 | ||
|
|
7a271c3549 | ||
|
|
dac23ff1e6 | ||
|
|
25f9b4b1ef | ||
|
|
c1c18fd5d4 | ||
|
|
63c03139b6 | ||
|
|
1c33dcf5aa | ||
|
|
4bcdd5b7e8 | ||
|
|
80f1f4e445 | ||
|
|
5a67c5dbd6 | ||
|
|
f201de8f40 | ||
|
|
b9de5e8a71 | ||
|
|
72d72a15b7 | ||
|
|
7696285288 | ||
|
|
a2bcbb5725 | ||
|
|
89a29a952c | ||
|
|
c498642826 | ||
|
|
52afd5e969 | ||
|
|
4b6896be3c | ||
|
|
845a3e7faf | ||
|
|
46c0c939aa | ||
|
|
42b22218a1 | ||
|
|
cfdcd9041b | ||
|
|
3e9974b226 | ||
|
|
7a47efa112 | ||
|
|
616687e3de | ||
|
|
56bc672173 | ||
|
|
bdba3542bb | ||
|
|
4b7fc6d645 | ||
|
|
6ea0ee5410 | ||
|
|
731cca9c29 | ||
|
|
41ec800910 | ||
|
|
4e04c557d6 | ||
|
|
72c03a4820 | ||
|
|
184b55c89e | ||
|
|
c5d4e936d5 | ||
|
|
15bf5c5844 | ||
|
|
5d5e9e8cc1 | ||
|
|
e18c60018c | ||
|
|
62c8720316 | ||
|
|
4c65040b4b | ||
|
|
240039f48d | ||
|
|
95d3e80004 | ||
|
|
1d88de5535 | ||
|
|
e91b460ce9 | ||
|
|
92814874ed | ||
|
|
270a61cd1e | ||
|
|
e16c72aafc | ||
|
|
1aee1e8cb8 | ||
|
|
1e039bfae2 | ||
|
|
eba6185a5f | ||
|
|
837926cc0b | ||
|
|
8381fa4f44 | ||
|
|
0db01f7f28 | ||
|
|
26b895baa6 | ||
|
|
354b451605 | ||
|
|
1c15f92f54 | ||
|
|
c7d2986c16 | ||
|
|
41045f422b | ||
|
|
bd86923b2f | ||
|
|
de1824274c | ||
|
|
e78244f4d3 | ||
|
|
6fd96a5e50 | ||
|
|
3ae71e5529 | ||
|
|
4f3e9cfc13 | ||
|
|
e78da02f0d | ||
|
|
e250ed3001 | ||
|
|
37e49e75d1 | ||
|
|
188b94ece2 | ||
|
|
6b1d4fa64c | ||
|
|
ece909f2e1 | ||
|
|
fd3be84b33 | ||
|
|
a86520234e | ||
|
|
4b60c0440e | ||
|
|
373c3c1257 | ||
|
|
9cade98737 | ||
|
|
79acc066e1 | ||
|
|
e1af334559 | ||
|
|
0776bb0065 | ||
|
|
47b7b492d3 | ||
|
|
aaa8e15791 | ||
|
|
2fbeffeb76 | ||
|
|
773916e18d | ||
|
|
63416a3cde | ||
|
|
9240b38feb | ||
|
|
0c62340657 | ||
|
|
d5c8d5574a | ||
|
|
becccf108f | ||
|
|
3cbafbf91f | ||
|
|
820f7b1c83 | ||
|
|
78b1de5141 | ||
|
|
051cab88a3 | ||
|
|
28973c8803 | ||
|
|
f1f1a49caf | ||
|
|
698121569b | ||
|
|
ca55161581 | ||
|
|
1fb8f150fc | ||
|
|
168fc4c63a | ||
|
|
aa4cd1b945 | ||
|
|
c47e8a0dd4 | ||
|
|
01e513cbfd | ||
|
|
cac9eb9eeb | ||
|
|
44896db4ff | ||
|
|
cd27000fef | ||
|
|
a2c8c22a42 | ||
|
|
49db1d61cb | ||
|
|
09ed0b16a8 | ||
|
|
31a0a7f893 | ||
|
|
29ed4c47af | ||
|
|
2d269e5714 | ||
|
|
42623f8723 | ||
|
|
072181692d | ||
|
|
ff129e1c53 | ||
|
|
55019721a1 | ||
|
|
4eb9368cc7 | ||
|
|
26a61b8d69 | ||
|
|
2c6e0c8145 | ||
|
|
dab91681c9 | ||
|
|
6452f18c36 | ||
|
|
c7694ff2e7 | ||
|
|
9f6e9af634 | ||
|
|
0be46fc400 | ||
|
|
d9c464d7d5 | ||
|
|
287a21457e | ||
|
|
42eafa6d6b | ||
|
|
95f4511b0d | ||
|
|
cd77b06a59 | ||
|
|
f5f58d4ae5 | ||
|
|
5ae344409a | ||
|
|
e3dfe324e8 | ||
|
|
0feb065e48 | ||
|
|
838c115a2c | ||
|
|
61939e5e46 | ||
|
|
bcbc64c635 | ||
|
|
c65a627045 | ||
|
|
440625949b | ||
|
|
f70a9b2937 | ||
|
|
a1824c9079 | ||
|
|
d022f068cd | ||
|
|
85fc95bac8 | ||
|
|
b115002d07 | ||
|
|
3deb4bda7b | ||
|
|
e797340466 | ||
|
|
fdfdd25d23 | ||
|
|
4740942e8b | ||
|
|
d219f2b8ba | ||
|
|
18980ecdc2 | ||
|
|
fad22512cd | ||
|
|
083e05d412 | ||
|
|
88ea213c35 | ||
|
|
85b18a94d6 | ||
|
|
7670e04879 | ||
|
|
fc84e09738 | ||
|
|
177b9a3b39 | ||
|
|
4a4a7603b5 | ||
|
|
6a1778eaf3 | ||
|
|
72a86b56ac | ||
|
|
9dd7d1f044 | ||
|
|
cdb4ab5c80 | ||
|
|
97e3045a7c | ||
|
|
f43a99b4fe | ||
|
|
ccac7ba14f | ||
|
|
d7df7fe3c0 | ||
|
|
2bf6af1ff0 | ||
|
|
860d901115 | ||
|
|
524a93203b | ||
|
|
a6b26ff1a6 | ||
|
|
6f6cf2f57c | ||
|
|
6b293e9eaa | ||
|
|
a7dac3434d | ||
|
|
653bf84829 | ||
|
|
ba1d5ec943 | ||
|
|
994f520df1 | ||
|
|
380f6a548b | ||
|
|
a88f8dabe7 | ||
|
|
885ccf43ba | ||
|
|
7cbef302ea | ||
|
|
76afdb2e89 | ||
|
|
ea8c2da594 | ||
|
|
1b958c4b3f | ||
|
|
0c9330e4e6 | ||
|
|
da281de1d1 | ||
|
|
f73114a381 | ||
|
|
b3fc86ac3b | ||
|
|
018fea60fa | ||
|
|
29cab5c0b8 | ||
|
|
169fc05087 | ||
|
|
69a1a4f2d4 | ||
|
|
06c3bbaa7c | ||
|
|
0d12877d89 | ||
|
|
8aca3975d1 | ||
|
|
bc6dcf8395 | ||
|
|
f5806ba961 | ||
|
|
ed3ed28d2b | ||
|
|
36aa8425e6 | ||
|
|
9c069c9cfe | ||
|
|
b612dec12c | ||
|
|
c91342ac9d | ||
|
|
ad643fcf78 | ||
|
|
f85fe496e6 | ||
|
|
03db4cad8c | ||
|
|
9f41cdc3f5 | ||
|
|
6cd2dc1c9d | ||
|
|
74aaa4f643 | ||
|
|
e7d9bd4ea8 | ||
|
|
7b1c29f3cf | ||
|
|
ac0cecd1d1 | ||
|
|
cdcefb97d5 | ||
|
|
3edafe58dd | ||
|
|
56ec883fb7 | ||
|
|
a7090cea7e | ||
|
|
03a2cd1bd7 | ||
|
|
2826bb5fc5 | ||
|
|
63adea29c1 | ||
|
|
739e805b83 | ||
|
|
c7fa862792 | ||
|
|
58e2d2978f | ||
|
|
4f56ad408d | ||
|
|
8e52e61c39 | ||
|
|
c80ef798e5 | ||
|
|
528ad2f62b | ||
|
|
33adceae7d | ||
|
|
06de88a367 | ||
|
|
4f666d9f8f | ||
|
|
7dea1ede4f | ||
|
|
ead4190584 | ||
|
|
21d6a5f719 | ||
|
|
aab0526f52 | ||
|
|
41b771a185 | ||
|
|
66e020f615 | ||
|
|
38ca03ef57 | ||
|
|
b2eb0c5f17 | ||
|
|
f39cd023f6 | ||
|
|
12b16cb026 | ||
|
|
1f4e31deef | ||
|
|
c3f1af5157 | ||
|
|
dc3f6d0fa9 | ||
|
|
c8816364f7 | ||
|
|
f468b99f76 | ||
|
|
a93f118d03 | ||
|
|
adedde8e46 | ||
|
|
d84539fc42 | ||
|
|
0453ac83e5 | ||
|
|
a486628e18 | ||
|
|
6894bb026e | ||
|
|
34fbe3510b | ||
|
|
972519ba2d | ||
|
|
42faf6ad34 | ||
|
|
67847fed2a | ||
|
|
5cfdf10e5c | ||
|
|
124ae27c0b | ||
|
|
0f87ce0b6a | ||
|
|
25feadbe37 | ||
|
|
c744598425 | ||
|
|
39505896a6 | ||
|
|
035fecd17b | ||
|
|
b799cf38df | ||
|
|
a21880ea13 | ||
|
|
d918125a04 | ||
|
|
baf5cf101f | ||
|
|
5f6e983364 | ||
|
|
bb408d7f0e | ||
|
|
13601ebe1d | ||
|
|
5eaccd3062 | ||
|
|
56ea5714af | ||
|
|
8786c76ff4 | ||
|
|
dfe7510ca0 | ||
|
|
507b6d355f | ||
|
|
19c2d6d054 | ||
|
|
55942342d5 | ||
|
|
7fee71e702 | ||
|
|
11974accdb | ||
|
|
6dd4d40234 | ||
|
|
9a49b5306c | ||
|
|
6254399add | ||
|
|
f6f88de508 | ||
|
|
9f4205be32 | ||
|
|
9262df465a | ||
|
|
66187eef70 | ||
|
|
8d61a7e944 | ||
|
|
7653e860dd | ||
|
|
6748009181 | ||
|
|
e7787b8bb8 | ||
|
|
2532e0ff59 | ||
|
|
f899004140 | ||
|
|
8f418c59a5 | ||
|
|
2540c5211c | ||
|
|
e725226f4e | ||
|
|
7c15e38b43 | ||
|
|
3825120b57 | ||
|
|
fa7c9ef49e | ||
|
|
20fc49d7db | ||
|
|
36f69f4da9 | ||
|
|
6eb8242cf7 | ||
|
|
29d7d0f62a | ||
|
|
2684e0f316 | ||
|
|
68d05ea27c | ||
|
|
4f0ce82f4a | ||
|
|
e1e0614199 | ||
|
|
eda4d3e2d5 | ||
|
|
e6ae58e989 | ||
|
|
786ccad3c4 | ||
|
|
2982646dee | ||
|
|
6c57b0ca9b | ||
|
|
b281fbd5fa | ||
|
|
d926a5cc00 | ||
|
|
53914d2af2 | ||
|
|
84758d60b3 | ||
|
|
1f7623e7f5 | ||
|
|
54b302683a | ||
|
|
26a579ba7c | ||
|
|
28b12df340 | ||
|
|
37ea6d4fc8 | ||
|
|
0d2293525e | ||
|
|
5c6030f413 | ||
|
|
1b703526ea | ||
|
|
4af7777f49 | ||
|
|
0c95c816b5 | ||
|
|
14eb349212 | ||
|
|
a812c94f67 | ||
|
|
7406cb4aac | ||
|
|
9a128d38b4 | ||
|
|
3377bf96a6 | ||
|
|
884eb047f9 | ||
|
|
235842e6a9 | ||
|
|
caa43b6f2a | ||
|
|
e9b6e5fcb8 | ||
|
|
73692376ef | ||
|
|
7407f12d07 | ||
|
|
3ad44a1811 | ||
|
|
ec42060ddf | ||
|
|
268dad2018 | ||
|
|
d170a7e205 | ||
|
|
eb6532e573 | ||
|
|
9380e98865 | ||
|
|
069adbc5ab | ||
|
|
2a09c1c2b2 | ||
|
|
c90a523e95 | ||
|
|
4489ebfcf3 | ||
|
|
381b4a9dd5 | ||
|
|
cbe30ef3a2 | ||
|
|
192eecb1ec | ||
|
|
a3fed878ca | ||
|
|
517f939b66 | ||
|
|
d6777a5ce4 | ||
|
|
75e112be4c | ||
|
|
c8123aaa5b | ||
|
|
c109522318 | ||
|
|
5076569a0e | ||
|
|
845fe37b86 | ||
|
|
9bdf6fb0a0 | ||
|
|
692c63b787 | ||
|
|
ffc2a5b008 | ||
|
|
8ad26fd0ae | ||
|
|
96eca282b6 | ||
|
|
f62f75d585 | ||
|
|
894684b1e5 | ||
|
|
952b15deca | ||
|
|
08a8d9ed77 | ||
|
|
73aee8e976 | ||
|
|
f61e216c30 | ||
|
|
274a944fa2 | ||
|
|
3d87ea5797 | ||
|
|
f0e80bc97f | ||
|
|
6f0c84556a | ||
|
|
a3e368733a | ||
|
|
786ecff8ce | ||
|
|
246df32553 | ||
|
|
04bf3be779 | ||
|
|
2ca2dda15b | ||
|
|
9f4e5643ce | ||
|
|
4ad6410ca3 | ||
|
|
7ca1b011ee | ||
|
|
de5f9a4b73 | ||
|
|
99c5ecf737 | ||
|
|
d80c8c7326 | ||
|
|
ee8cba7daa | ||
|
|
428e6bd69c | ||
|
|
9026e2de17 | ||
|
|
0818de9f9f | ||
|
|
76d34a56ac | ||
|
|
75d2570524 | ||
|
|
aa2cac1948 | ||
|
|
3fa23a6e47 | ||
|
|
b3acccac7c | ||
|
|
2f56bc7802 | ||
|
|
21f90fd0fd | ||
|
|
d70c637541 | ||
|
|
a53ff2cc73 | ||
|
|
d56bb2138f | ||
|
|
ae1804daba | ||
|
|
94f8970efd | ||
|
|
fd0e6ca314 | ||
|
|
c17fbdc8ea | ||
|
|
e63faca4cd | ||
|
|
607d3a9070 | ||
|
|
f95344367c | ||
|
|
4411e41e42 | ||
|
|
95030c353a | ||
|
|
26085752a1 | ||
|
|
3b20148a14 | ||
|
|
3883b96c14 | ||
|
|
1d3ee9e09b | ||
|
|
e32ff2ed5a | ||
|
|
bcf5313f3c | ||
|
|
8b30a42b65 | ||
|
|
b755a5ca1e | ||
|
|
e5ed89083d | ||
|
|
4d5943a784 | ||
|
|
ea8797c03c | ||
|
|
cfdad51e8e | ||
|
|
5204b43077 |
@@ -44,3 +44,6 @@ crates/dbx-core/assets/docs-export.css -text -diff linguist-generated
|
||||
# parser must not depend on the contributor's core.autocrlf: a CRLF checkout
|
||||
# silently rewrites all three files.
|
||||
src-tauri/tests/fixtures/** text eol=lf
|
||||
|
||||
# Preserve the upstream SQL attachment bytes used by the live MySQL regression.
|
||||
crates/dbx-core/tests/fixtures/qrtz.sql -text
|
||||
|
||||
+2
-2
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.43.0"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
|
||||
checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
|
||||
@@ -5,6 +5,6 @@ edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
aws-lc-sys = { version = "=0.43.0", features = ["prebuilt-nasm"] }
|
||||
aws-lc-sys = { version = "=0.46.0", features = ["prebuilt-nasm"] }
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { detectIssueCommand, handleIssueCommand } from "./issue-commands.mjs";
|
||||
import { writeContributorSnapshot } from "../../docs/scripts/contributor-snapshot.mjs";
|
||||
|
||||
function workflow(name) {
|
||||
return readFileSync(new URL(`../workflows/${name}.yml`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
function job(content, name) {
|
||||
const jobs = content.slice(content.indexOf("\njobs:\n") + 7);
|
||||
const definitions = [...jobs.matchAll(/^ ([\w-]+):\s*$/gm)];
|
||||
const index = definitions.findIndex((match) => match[1] === name);
|
||||
assert.ok(index >= 0, `missing job ${name}`);
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function evaluateCondition(content, github) {
|
||||
const folded = content.match(/^ if: >-\n((?: [^\n]*\n)+)/m);
|
||||
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
|
||||
assert.ok(expression, "missing job condition");
|
||||
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
|
||||
return evaluate(github, (search, item) =>
|
||||
String(search ?? "")
|
||||
.toLowerCase()
|
||||
.includes(String(item).toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
|
||||
return {
|
||||
repository_owner: "t8y2",
|
||||
event: {
|
||||
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
|
||||
comment: { body, user: { type: userType, login } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
|
||||
const content = job(workflow("issue-claim"), "claim");
|
||||
for (const body of ["/claim", "please /claim this", "\t/claim\n", "/unclaim", " /unclaimed\n", "\t/close\r\n"]) {
|
||||
assert.ok(detectIssueCommand(body), body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), false, body);
|
||||
}
|
||||
for (const body of ["Thanks!", "Fixed in abc123", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["/claimable", "please /close", "/unclaim more", "/CLAIM"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true);
|
||||
assert.deepEqual(await handleIssueCommand({ commentBody: body, commentUserType: "User", client: {} }), { status: "ignored" });
|
||||
}
|
||||
});
|
||||
|
||||
test("spam prefilter retains all existing malicious-link cases and never bypasses the matcher", () => {
|
||||
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
|
||||
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
|
||||
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
|
||||
for (const body of [
|
||||
"https://evil.example/update.zip",
|
||||
"https://evil.example/update.ZIP?download=1",
|
||||
"[update.zip](//evil.example/download)",
|
||||
"[update.ZIP](https://evil%2Eexample/download)",
|
||||
"[update.7z](https://evil.example/download)",
|
||||
"[update.exe](https://evil.example/download)",
|
||||
"[update.SCR](//evil.example/download)",
|
||||
]) {
|
||||
assert.equal(matcher.test(body), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { login: "t8y2" })), false);
|
||||
assert.equal(evaluateCondition(content, commentEvent(body, { pullRequest: true })), true);
|
||||
}
|
||||
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), false);
|
||||
}
|
||||
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip", "https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)", "[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
|
||||
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
|
||||
assert.equal(matcher.test(body), false, body);
|
||||
}
|
||||
});
|
||||
|
||||
test("PR notification automation stays disabled while its configuration is retained", () => {
|
||||
const content = workflow("notify");
|
||||
assert.match(content, /# pull_request_target:\s+#\s+types: \[closed\]/);
|
||||
assert.match(content, /^ workflow_dispatch:/m);
|
||||
assert.doesNotMatch(content, /^ (?:pull_request_target|pull_request|issues|release|push|schedule):/m);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: true } } }), true);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: { merged: false } } }), false);
|
||||
assert.equal(evaluateCondition(job(content, "notify"), { event: { pull_request: {} } }), false);
|
||||
});
|
||||
|
||||
test("plugin checks run once before unchanged cross-platform verification", () => {
|
||||
const content = workflow("plugin-dev-host");
|
||||
const checks = job(content, "checks");
|
||||
const tests = job(content, "test");
|
||||
assert.match(checks, /runs-on: ubuntu-24\.04/);
|
||||
assert.match(checks, /npm run lint --prefix plugins\/sdk\/dev-host/);
|
||||
assert.match(checks, /npm run fmt:check --prefix plugins\/sdk\/dev-host/);
|
||||
assert.doesNotMatch(checks, /cargo|setup-go|rust-toolchain/);
|
||||
assert.match(tests, /needs: checks/);
|
||||
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
|
||||
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
|
||||
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc", "npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
|
||||
assert.ok(tests.includes(command), command);
|
||||
}
|
||||
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
|
||||
});
|
||||
|
||||
test("contributor refresh is weekly and remains manually available", () => {
|
||||
const content = workflow("contributors");
|
||||
assert.match(content, /cron: "0 18 \* \* 0"/);
|
||||
assert.match(content, /workflow_dispatch:/);
|
||||
});
|
||||
|
||||
test("GitHub script selection excludes planner tests only when fast checks own them", () => {
|
||||
const content = job(workflow("ci"), "github-scripts");
|
||||
const script = content.match(/run: \|\n((?: [^\n]*\n)+)/)[1].replace(/^ /gm, "");
|
||||
const repository = new URL("../../", import.meta.url);
|
||||
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
|
||||
for (const fast of ["true", "false", ""]) {
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
|
||||
cwd: repository,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FAST_CHECKS: fast },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const selected = result.stdout.trim().split("\n").slice(1).sort();
|
||||
const expected = files
|
||||
.filter((file) => fast !== "true" || !file.startsWith("ci-"))
|
||||
.map((file) => `.github/scripts/${file}`)
|
||||
.sort();
|
||||
assert.deepEqual(selected, expected);
|
||||
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
|
||||
}
|
||||
});
|
||||
|
||||
async function snapshotFixture(context) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "dbx-contributor-snapshot-"));
|
||||
context.after(() => rm(directory, { recursive: true, force: true }));
|
||||
return join(directory, "data", "contributors.json");
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10, contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
|
||||
}
|
||||
|
||||
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
assert.equal(await writeContributorSnapshot(outputPath, original), true);
|
||||
const originalBytes = await readFile(outputPath, "utf8");
|
||||
assert.equal(originalBytes, `${JSON.stringify(original, null, 2)}\n`);
|
||||
const refreshed = { ...original, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), false);
|
||||
assert.equal(await readFile(outputPath, "utf8"), originalBytes);
|
||||
assert.equal(refreshed.generatedAt, "2026-10-08T18:00:00.000Z");
|
||||
});
|
||||
|
||||
test("actual contributor, star and repository changes update the snapshot", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
const original = snapshot();
|
||||
for (const updated of [
|
||||
{ ...original, stars: 11 },
|
||||
{ ...original, repository: "other/dbx" },
|
||||
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
|
||||
{ ...original, contributors: [] },
|
||||
]) {
|
||||
await writeContributorSnapshot(outputPath, original);
|
||||
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
|
||||
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
|
||||
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), refreshed);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid contributor snapshots and filesystem errors are not silently overwritten", async (context) => {
|
||||
const outputPath = await snapshotFixture(context);
|
||||
await writeContributorSnapshot(outputPath, snapshot());
|
||||
await writeFile(outputPath, "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(outputPath, snapshot()), SyntaxError);
|
||||
assert.equal(await readFile(outputPath, "utf8"), "invalid JSON");
|
||||
await assert.rejects(writeContributorSnapshot(join(outputPath, "invalid"), snapshot()), { code: "ENOTDIR" });
|
||||
});
|
||||
@@ -52,26 +52,30 @@ If input is truncated, assess only visible evidence and express uncertainty abou
|
||||
class PriorityError extends Error {}
|
||||
|
||||
function labelNames(issue) {
|
||||
return (issue.labels || []).map((label) => typeof label === "string" ? label : label.name).filter(Boolean);
|
||||
return (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
function withoutUserPriority(body) {
|
||||
let inPrioritySection = false;
|
||||
return String(body || "").split(/\r?\n/).filter((line) => {
|
||||
const heading = line.match(/^#{1,6}\s+(.+)/);
|
||||
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
|
||||
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
|
||||
}).join("\n").trim();
|
||||
return String(body || "")
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => {
|
||||
const heading = line.match(/^#{1,6}\s+(.+)/);
|
||||
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
|
||||
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function prepareIssue(issue) {
|
||||
const body = withoutUserPriority(issue.body);
|
||||
return {
|
||||
title: String(issue.title || "").slice(0, 500),
|
||||
body: body.length > MAX_BODY_LENGTH
|
||||
? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}`
|
||||
: body,
|
||||
labels: labelNames(issue).filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)).sort(),
|
||||
body: body.length > MAX_BODY_LENGTH ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` : body,
|
||||
labels: labelNames(issue)
|
||||
.filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label))
|
||||
.sort(),
|
||||
truncated: body.length > MAX_BODY_LENGTH,
|
||||
};
|
||||
}
|
||||
@@ -83,21 +87,26 @@ export function parseAssessment(content, issue) {
|
||||
} catch {
|
||||
throw new PriorityError("AtlasCloud returned invalid priority JSON");
|
||||
}
|
||||
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3
|
||||
&& value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
|
||||
if (!result || typeof result.priority !== "string" || !Object.hasOwn(PRIORITY_LABELS, result.priority)
|
||||
|| !["high", "medium", "low"].includes(result.confidence)
|
||||
|| !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk)
|
||||
|| typeof result.reason !== "string" || !result.reason.trim() || result.reason.length > 600
|
||||
|| !validTextList(result.evidence, 300) || !validTextList(result.missing_information, 250)) {
|
||||
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
|
||||
if (
|
||||
!result ||
|
||||
typeof result.priority !== "string" ||
|
||||
!Object.hasOwn(PRIORITY_LABELS, result.priority) ||
|
||||
!["high", "medium", "low"].includes(result.confidence) ||
|
||||
!["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) ||
|
||||
typeof result.reason !== "string" ||
|
||||
!result.reason.trim() ||
|
||||
result.reason.length > 600 ||
|
||||
!validTextList(result.evidence, 300) ||
|
||||
!validTextList(result.missing_information, 250)
|
||||
) {
|
||||
throw new PriorityError("AtlasCloud returned an invalid priority assessment");
|
||||
}
|
||||
if (!result.evidence.every((quote) => issue.title.includes(quote) || issue.body.includes(quote))) {
|
||||
throw new PriorityError("AtlasCloud priority evidence does not match the issue text");
|
||||
}
|
||||
const insufficientEvidence = result.confidence === "low" || result.evidence.length === 0 || result.risk === "unknown";
|
||||
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high"
|
||||
|| !["security", "data-loss", "core-blocked"].includes(result.risk));
|
||||
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" || !["security", "data-loss", "core-blocked"].includes(result.risk));
|
||||
return {
|
||||
priority: insufficientEvidence || unsupportedCriticalPriority ? "needs-info" : result.priority,
|
||||
confidence: result.confidence,
|
||||
@@ -219,8 +228,7 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
|
||||
await github("POST", `${issuePath}/labels`, { labels: [targetLabel] });
|
||||
}
|
||||
for (const label of existingLabels) {
|
||||
if (label !== targetLabel && label.startsWith(LABEL_PREFIX)
|
||||
&& Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
|
||||
if (label !== targetLabel && label.startsWith(LABEL_PREFIX) && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
|
||||
try {
|
||||
await github("DELETE", `${issuePath}/labels/${encodeURIComponent(label)}`);
|
||||
} catch (error) {
|
||||
@@ -234,10 +242,12 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
|
||||
export function formatSummary(result) {
|
||||
const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
if (result.skipped) return `### AI issue priority\n\nSkipped: ${escape(result.skipped)}\n`;
|
||||
return `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n`
|
||||
+ "AI suggestion based on issue text only, not a verified diagnosis or release commitment. "
|
||||
+ "User-reported and maintainer priority labels are unchanged.\n\n"
|
||||
+ `<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`;
|
||||
return (
|
||||
`### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n` +
|
||||
"AI suggestion based on issue text only, not a verified diagnosis or release commitment. " +
|
||||
"User-reported and maintainer priority labels are unchanged.\n\n" +
|
||||
`<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assessIssue,
|
||||
createGitHubClient,
|
||||
DEFAULT_MODEL,
|
||||
formatSummary,
|
||||
parseAssessment,
|
||||
prepareIssue,
|
||||
run,
|
||||
} from "./ai-issue-priority.mjs";
|
||||
import { assessIssue, createGitHubClient, DEFAULT_MODEL, formatSummary, parseAssessment, prepareIssue, run } from "./ai-issue-priority.mjs";
|
||||
|
||||
const baseIssue = {
|
||||
number: 42,
|
||||
state: "open",
|
||||
title: "[Bug] Grid column alignment is inconsistent",
|
||||
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n"
|
||||
+ "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
|
||||
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n" + "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }, { name: "user-priority/P0" }],
|
||||
};
|
||||
|
||||
@@ -51,7 +42,9 @@ function fixture({ issue = baseIssue, latest = issue, result = assessment(), act
|
||||
if (method === "GET") return ++reads === 1 ? issue : latest;
|
||||
return {};
|
||||
},
|
||||
fetchImpl: modelFetch(result, () => { modelCalls += 1; }),
|
||||
fetchImpl: modelFetch(result, () => {
|
||||
modelCalls += 1;
|
||||
}),
|
||||
};
|
||||
return { options, calls, modelCalls: () => modelCalls };
|
||||
}
|
||||
@@ -114,14 +107,7 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
|
||||
for (const priority of ["P1", "P2", "P3", "needs-info"]) {
|
||||
assert.equal(parseAssessment(JSON.stringify(assessment({ priority })), prepared).priority, priority);
|
||||
}
|
||||
for (const override of [
|
||||
{ confidence: "low" },
|
||||
{ evidence: [] },
|
||||
{ risk: "unknown" },
|
||||
{ priority: "P0", risk: "cosmetic" },
|
||||
{ priority: "P0", risk: "feature" },
|
||||
{ priority: "P0", risk: "data-loss", confidence: "medium" },
|
||||
]) {
|
||||
for (const override of [{ confidence: "low" }, { evidence: [] }, { risk: "unknown" }, { priority: "P0", risk: "cosmetic" }, { priority: "P0", risk: "feature" }, { priority: "P0", risk: "data-loss", confidence: "medium" }]) {
|
||||
assert.equal(parseAssessment(JSON.stringify(assessment(override)), prepared).priority, "needs-info");
|
||||
}
|
||||
const criticalIssue = prepareIssue({ title: "Data loss", body: "Saving one row overwrites every row in the table." });
|
||||
@@ -132,13 +118,25 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
|
||||
test("rejects malformed, oversized, unknown and fabricated model results", () => {
|
||||
const prepared = prepareIssue(baseIssue);
|
||||
for (const invalid of [
|
||||
"not JSON", "null", "[]", "```json\n{}\n```",
|
||||
"not JSON",
|
||||
"null",
|
||||
"[]",
|
||||
"```json\n{}\n```",
|
||||
...[
|
||||
{ priority: "P9" }, { priority: "__proto__" }, { priority: "constructor" }, { priority: ["P0"] },
|
||||
{ confidence: "certain" }, { risk: "urgent" }, { reason: " " }, { reason: "x".repeat(601) },
|
||||
{ evidence: ["Invented widespread data loss"] }, { evidence: ["P0 Must fix next release"] },
|
||||
{ evidence: [""] }, { evidence: "not an array" },
|
||||
{ missing_information: ["x".repeat(251)] }, { missing_information: ["a", "b", "c", "d"] },
|
||||
{ priority: "P9" },
|
||||
{ priority: "__proto__" },
|
||||
{ priority: "constructor" },
|
||||
{ priority: ["P0"] },
|
||||
{ confidence: "certain" },
|
||||
{ risk: "urgent" },
|
||||
{ reason: " " },
|
||||
{ reason: "x".repeat(601) },
|
||||
{ evidence: ["Invented widespread data loss"] },
|
||||
{ evidence: ["P0 Must fix next release"] },
|
||||
{ evidence: [""] },
|
||||
{ evidence: "not an array" },
|
||||
{ missing_information: ["x".repeat(251)] },
|
||||
{ missing_information: ["a", "b", "c", "d"] },
|
||||
].map((override) => JSON.stringify(assessment(override))),
|
||||
]) {
|
||||
assert.throws(() => parseAssessment(invalid, prepared));
|
||||
@@ -151,14 +149,22 @@ test("labels independently of reporter and maintainer priority, removing only ob
|
||||
const result = await run(state.options);
|
||||
assert.equal(result.label, "ai-priority/P3");
|
||||
assert.equal(state.modelCalls(), 1);
|
||||
assert.deepEqual(state.calls.filter(({ method }) => method !== "GET"), [
|
||||
{ method: "POST", path: "/labels", body: {
|
||||
name: "ai-priority/P3", color: "0e8a16",
|
||||
description: "AI: low repair/implementation priority; minor or optional improvement",
|
||||
} },
|
||||
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
|
||||
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
state.calls.filter(({ method }) => method !== "GET"),
|
||||
[
|
||||
{
|
||||
method: "POST",
|
||||
path: "/labels",
|
||||
body: {
|
||||
name: "ai-priority/P3",
|
||||
color: "0e8a16",
|
||||
description: "AI: low repair/implementation priority; minor or optional improvement",
|
||||
},
|
||||
},
|
||||
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
|
||||
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("does not rewrite an unchanged AI label", async () => {
|
||||
@@ -187,8 +193,7 @@ test("skips missing key, unrelated events, PRs, closed and opted-out issues befo
|
||||
test("edits reconcile priority even when a preceding workflow may have been canceled", async () => {
|
||||
for (const config of [
|
||||
{ action: "edited", changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
|
||||
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] },
|
||||
changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
|
||||
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] }, changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
|
||||
{ action: "reopened" },
|
||||
{ action: "edited", changes: { title: { from: "Old title" } } },
|
||||
{ action: "edited", changes: { body: { from: "Old body" } } },
|
||||
@@ -211,10 +216,7 @@ test("uses fresh issue content and rechecks eligibility before paid assessment",
|
||||
});
|
||||
|
||||
test("does not publish stale results after changes, closure or maintainer opt-out", async () => {
|
||||
for (const override of [
|
||||
{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] },
|
||||
{ state: "closed" }, { labels: ["ai-priority/skip"] },
|
||||
]) {
|
||||
for (const override of [{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] }, { state: "closed" }, { labels: ["ai-priority/skip"] }]) {
|
||||
const state = fixture({ latest: { ...baseIssue, ...override } });
|
||||
assert.equal((await run(state.options)).skipped, "issue changed during assessment");
|
||||
assert.equal(state.modelCalls(), 1);
|
||||
@@ -247,7 +249,9 @@ test("provider failures and invalid outputs preserve all existing labels and do
|
||||
async () => Response.json({ error: "secret-response-body" }, { status: 401 }),
|
||||
async () => Response.json({ error: "secret-response-body" }, { status: 429 }),
|
||||
async () => Response.json({ error: "secret-response-body" }, { status: 500 }),
|
||||
async () => { throw new Error("secret-response-body"); },
|
||||
async () => {
|
||||
throw new Error("secret-response-body");
|
||||
},
|
||||
async () => new Response("secret-response-body"),
|
||||
async () => Response.json({ choices: [] }),
|
||||
async () => Response.json({ choices: [{ finish_reason: "length", message: { content: "secret-response-body" } }] }),
|
||||
@@ -282,7 +286,10 @@ test("failed label addition never removes the previous AI priority", async () =>
|
||||
return original(method, path, body);
|
||||
};
|
||||
await assert.rejects(run(state.options), /GitHub unavailable/);
|
||||
assert.equal(state.calls.some(({ method }) => method === "DELETE"), false);
|
||||
assert.equal(
|
||||
state.calls.some(({ method }) => method === "DELETE"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid issue numbers are rejected before requests", async () => {
|
||||
@@ -299,7 +306,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
|
||||
assert.throws(() => createGitHubClient({ token: "fixture-token", repository: "https://other.example" }));
|
||||
const calls = [];
|
||||
const github = createGitHubClient({
|
||||
token: "fixture-token", repository: "t8y2/dbx",
|
||||
token: "fixture-token",
|
||||
repository: "t8y2/dbx",
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json(baseIssue);
|
||||
@@ -311,7 +319,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
|
||||
assert.ok(calls[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(await github("DELETE", "/issues/42/labels/test"), null);
|
||||
const failing = createGitHubClient({
|
||||
token: "fixture-token", repository: "t8y2/dbx",
|
||||
token: "fixture-token",
|
||||
repository: "t8y2/dbx",
|
||||
fetchImpl: async () => new Response("secret-response-body", { status: 403 }),
|
||||
});
|
||||
await assert.rejects(failing("GET", "/issues/42"), (error) => {
|
||||
|
||||
@@ -33,23 +33,14 @@ export function selectChunk(issues, { chunkIndex, chunkCount, maxIssues = 0 }) {
|
||||
export async function listOpenUnassignedIssues(client) {
|
||||
const issues = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const items = await client.request(
|
||||
"GET",
|
||||
`/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`,
|
||||
);
|
||||
const items = await client.request("GET", `/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`);
|
||||
issues.push(...items.filter((issue) => !issue.pull_request && (issue.assignees || []).length === 0));
|
||||
if (items.length < 100) break;
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function backfill({
|
||||
client,
|
||||
chunkIndex,
|
||||
chunkCount,
|
||||
maxIssues = 0,
|
||||
searchDelayMs = DEFAULT_SEARCH_DELAY_MS,
|
||||
}) {
|
||||
export async function backfill({ client, chunkIndex, chunkCount, maxIssues = 0, searchDelayMs = DEFAULT_SEARCH_DELAY_MS }) {
|
||||
const issues = selectChunk(await listOpenUnassignedIssues(client), { chunkIndex, chunkCount, maxIssues });
|
||||
const originalSearchIssues = client.searchIssues.bind(client);
|
||||
let lastSearchStartedAt = 0;
|
||||
@@ -98,10 +89,6 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
|
||||
chunkIndex: integerValue(process.env.BACKFILL_CHUNK_INDEX, 0, "BACKFILL_CHUNK_INDEX"),
|
||||
chunkCount: integerValue(process.env.BACKFILL_CHUNK_COUNT, 1, "BACKFILL_CHUNK_COUNT"),
|
||||
maxIssues: integerValue(process.env.BACKFILL_MAX_ISSUES, 0, "BACKFILL_MAX_ISSUES"),
|
||||
searchDelayMs: integerValue(
|
||||
process.env.BACKFILL_SEARCH_DELAY_MS,
|
||||
DEFAULT_SEARCH_DELAY_MS,
|
||||
"BACKFILL_SEARCH_DELAY_MS",
|
||||
),
|
||||
searchDelayMs: integerValue(process.env.BACKFILL_SEARCH_DELAY_MS, DEFAULT_SEARCH_DELAY_MS, "BACKFILL_SEARCH_DELAY_MS"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
backfill,
|
||||
listOpenUnassignedIssues,
|
||||
selectChunk,
|
||||
} from "./backfill-similar-issues.mjs";
|
||||
import { backfill, listOpenUnassignedIssues, selectChunk } from "./backfill-similar-issues.mjs";
|
||||
|
||||
test("selectChunk partitions issue numbers deterministically", () => {
|
||||
const issues = [1, 2, 3, 4, 5, 6].map((number) => ({ number }));
|
||||
|
||||
assert.deepEqual(selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number), [6, 3]);
|
||||
assert.deepEqual(selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number), [4, 1]);
|
||||
assert.deepEqual(
|
||||
selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number),
|
||||
[6, 3],
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number),
|
||||
[4, 1],
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectChunk(issues, { chunkIndex: 2, chunkCount: 3, maxIssues: 1 }).map(({ number }) => number),
|
||||
[5],
|
||||
@@ -34,8 +36,14 @@ test("listOpenUnassignedIssues filters pull requests and assigned issues across
|
||||
|
||||
const issues = await listOpenUnassignedIssues(client);
|
||||
assert.equal(issues.length, 99);
|
||||
assert.equal(issues.some(({ number }) => number === 1), false);
|
||||
assert.equal(issues.some(({ number }) => number === 2), false);
|
||||
assert.equal(
|
||||
issues.some(({ number }) => number === 1),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
issues.some(({ number }) => number === 2),
|
||||
false,
|
||||
);
|
||||
assert.equal(issues.at(-1).number, 101);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs
|
||||
const VERSIONS_PATH = "agents/versions.json";
|
||||
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
|
||||
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
|
||||
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([
|
||||
".github/scripts/reuse-agent-release-assets.mjs",
|
||||
"agents/scripts/version_agent_artifacts.py",
|
||||
]);
|
||||
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([".github/scripts/reuse-agent-release-assets.mjs", "agents/scripts/version_agent_artifacts.py"]);
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
|
||||
@@ -35,11 +32,7 @@ export function parseLegacyStandaloneProjects(buildGradle) {
|
||||
const match = /legacyStandaloneProjects\s*=\s*\[([^\]]*)\]/m.exec(buildGradle);
|
||||
if (!match) return new Set();
|
||||
|
||||
return new Set(
|
||||
[...match[1].matchAll(/['"]([^'"]+)['"]/g)]
|
||||
.map((entry) => entry[1])
|
||||
.filter(Boolean),
|
||||
);
|
||||
return new Set([...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((entry) => entry[1]).filter(Boolean));
|
||||
}
|
||||
|
||||
function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
|
||||
@@ -72,23 +65,10 @@ const crateNativeDriverDirectories = {
|
||||
};
|
||||
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]);
|
||||
const nativeDriverSharedPaths = {
|
||||
hive: [
|
||||
"agents/go-common/go-gssapi",
|
||||
"agents/go-common/gohive",
|
||||
"agents/go-common/gosasl",
|
||||
],
|
||||
argo: [
|
||||
"agents/go-common/go-gssapi",
|
||||
"agents/go-common/gohive",
|
||||
"agents/go-common/gosasl",
|
||||
],
|
||||
zookeeper: [
|
||||
"agents/go-common/go-gssapi",
|
||||
"agents/go-common/gosasl",
|
||||
],
|
||||
etcd: [
|
||||
"agents/go-common/go-semver",
|
||||
],
|
||||
hive: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
|
||||
argo: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
|
||||
zookeeper: ["agents/go-common/go-gssapi", "agents/go-common/gosasl"],
|
||||
etcd: ["agents/go-common/go-semver"],
|
||||
};
|
||||
|
||||
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
|
||||
@@ -137,16 +117,7 @@ function classifyModules(versions, options) {
|
||||
.filter(({ module }) => module);
|
||||
}
|
||||
|
||||
export function evaluateAgentVersionBump({
|
||||
versions,
|
||||
prevVersions = versions,
|
||||
changedFiles,
|
||||
legacyStandaloneModules = new Set(),
|
||||
moduleExists = existsSync,
|
||||
readModuleFile = (path) => readFileSync(path, "utf8"),
|
||||
skipBump = false,
|
||||
manualVersionsChanged = changedFiles.includes(VERSIONS_PATH),
|
||||
}) {
|
||||
export function evaluateAgentVersionBump({ versions, prevVersions = versions, changedFiles, legacyStandaloneModules = new Set(), moduleExists = existsSync, readModuleFile = (path) => readFileSync(path, "utf8"), skipBump = false, manualVersionsChanged = changedFiles.includes(VERSIONS_PATH) }) {
|
||||
const nextVersions = { ...versions };
|
||||
const logs = [];
|
||||
let changed = false;
|
||||
@@ -180,8 +151,7 @@ export function evaluateAgentVersionBump({
|
||||
}
|
||||
|
||||
for (const { moduleName, module } of resolvedModules) {
|
||||
const moduleChanged = [module.modulePath, ...module.sharedPaths]
|
||||
.some((path) => pathChanged(changedFiles, path));
|
||||
const moduleChanged = [module.modulePath, ...module.sharedPaths].some((path) => pathChanged(changedFiles, path));
|
||||
// Only modules that package agents/common need installer-visible updates
|
||||
// for shared Java runtime changes; native and standalone agents do not.
|
||||
const commonAffectsModule = commonChanged && module.commonDependent;
|
||||
@@ -233,22 +203,15 @@ function git(args) {
|
||||
}
|
||||
|
||||
function lines(value) {
|
||||
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
|
||||
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
|
||||
const versionCommits = lines(
|
||||
gitOutput([
|
||||
"log",
|
||||
"--reverse",
|
||||
"--ancestry-path",
|
||||
"--format=%H%x09%s",
|
||||
`${prevTag}..${headRef}`,
|
||||
"--",
|
||||
VERSIONS_PATH,
|
||||
]),
|
||||
);
|
||||
const versionCommits = lines(gitOutput(["log", "--reverse", "--ancestry-path", "--format=%H%x09%s", `${prevTag}..${headRef}`, "--", VERSIONS_PATH]));
|
||||
|
||||
let syncCommit = "";
|
||||
for (const entry of versionCommits) {
|
||||
@@ -266,12 +229,8 @@ export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutp
|
||||
|
||||
const versionsRef = syncCommit || prevTag;
|
||||
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
|
||||
const versionsChangedAfterSync = syncCommit
|
||||
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
|
||||
: false;
|
||||
const changedFiles = syncCommit && !versionsChangedAfterSync
|
||||
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
|
||||
: allChangedFiles;
|
||||
const versionsChangedAfterSync = syncCommit ? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0 : false;
|
||||
const changedFiles = syncCommit && !versionsChangedAfterSync ? allChangedFiles.filter((file) => file !== VERSIONS_PATH) : allChangedFiles;
|
||||
|
||||
return {
|
||||
prevTag,
|
||||
|
||||
@@ -118,11 +118,7 @@ test("bumps the native RocketMQ agent from its Go directory", () => {
|
||||
});
|
||||
|
||||
test("bumps ZooKeeper from native and shared SASL source directories", () => {
|
||||
for (const changedFile of [
|
||||
"agents/drivers/zookeeper/main.go",
|
||||
"agents/go-common/gosasl/sasl.go",
|
||||
"agents/go-common/go-gssapi/krb5/krb5.go",
|
||||
]) {
|
||||
for (const changedFile of ["agents/drivers/zookeeper/main.go", "agents/go-common/gosasl/sasl.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { zookeeper: "0.1.0" },
|
||||
changedFiles: [changedFile],
|
||||
@@ -161,12 +157,7 @@ test("bumps Cassandra from its native Go source directory", () => {
|
||||
});
|
||||
|
||||
test("bumps Hive from native and shared Kerberos source directories", () => {
|
||||
for (const changedFile of [
|
||||
"agents/drivers/hive-go/main.go",
|
||||
"agents/go-common/gohive/driver.go",
|
||||
"agents/go-common/gosasl/gssapi.go",
|
||||
"agents/go-common/go-gssapi/krb5/krb5.go",
|
||||
]) {
|
||||
for (const changedFile of ["agents/drivers/hive-go/main.go", "agents/go-common/gohive/driver.go", "agents/go-common/gosasl/gssapi.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { hive: "0.1.43" },
|
||||
changedFiles: [changedFile],
|
||||
@@ -207,12 +198,7 @@ test("bumps IoTDB from its native Go source directory", () => {
|
||||
});
|
||||
|
||||
test("rebuilds native modules when shared native packaging changes", () => {
|
||||
const existing = new Set([
|
||||
"agents/drivers/access",
|
||||
"agents/drivers/access/build.gradle",
|
||||
"agents/drivers/duckdb",
|
||||
"agents/drivers/neo4j-go",
|
||||
]);
|
||||
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/duckdb", "agents/drivers/neo4j-go"]);
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" },
|
||||
changedFiles: ["agents/scripts/version_agent_artifacts.py"],
|
||||
@@ -255,12 +241,7 @@ test("bumps DuckDB when its Cargo target configuration changes", () => {
|
||||
});
|
||||
|
||||
test("builds only common-dependent Java modules for a shared runtime change", () => {
|
||||
const existing = new Set([
|
||||
"agents/drivers/access",
|
||||
"agents/drivers/access/build.gradle",
|
||||
"agents/drivers/mongodb",
|
||||
"agents/drivers/mongodb/build.gradle",
|
||||
]);
|
||||
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/mongodb", "agents/drivers/mongodb/build.gradle"]);
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions: { access: "0.1.0", mongodb: "0.1.0" },
|
||||
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
|
||||
@@ -341,7 +322,7 @@ function createRepository(versions) {
|
||||
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
|
||||
writeVersions(repository, versions);
|
||||
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
|
||||
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
|
||||
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), '[package]\nname = "duckdb-test"\n');
|
||||
commitAll(repository, "feat(agents): initial release state");
|
||||
return repository;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
param(
|
||||
[ValidateRange(1, 5)][int]$MaxAttempts = 3,
|
||||
[ValidateRange(0, 120)][int]$RetryDelaySeconds = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Native failures are checked explicitly so transient downloads can be retried.
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$bundleDir = 'target/x86_64-win7-windows-msvc/release/bundle/nsis'
|
||||
|
||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json --config .github/fixtures/win7-nsis-zlib-config.json
|
||||
$bundleExitCode = $LASTEXITCODE
|
||||
if ($bundleExitCode -eq 0) { break }
|
||||
if ($attempt -eq $MaxAttempts) {
|
||||
throw "Windows 7 installer bundling failed after $MaxAttempts attempts (exit code $bundleExitCode)."
|
||||
}
|
||||
Write-Warning "Installer bundling attempt $attempt failed (exit code $bundleExitCode); retrying in $RetryDelaySeconds seconds."
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
|
||||
$installer = Get-ChildItem -LiteralPath $bundleDir -Filter '*.exe' -File |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
throw "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
}
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
@@ -0,0 +1,65 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$bundleScript = Join-Path $PSScriptRoot 'bundle-win7-installer.ps1'
|
||||
$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$testRoot = Join-Path $tempBase ('dbx-win7-bundle-test-' + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($testRoot) | Out-Null
|
||||
$originalLocation = Get-Location
|
||||
|
||||
function Assert-True($Condition, [string]$Message) { if (!$Condition) { throw $Message } }
|
||||
function pnpm {
|
||||
$global:DbxBundleTestState.Attempts++
|
||||
$global:DbxBundleTestState.Commands += ($args -join ' ')
|
||||
$global:LASTEXITCODE = if ($global:DbxBundleTestState.Attempts -le $global:DbxBundleTestState.Failures) { 1 } else { 0 }
|
||||
if ($LASTEXITCODE -eq 0 -and $global:DbxBundleTestState.EmitInstaller) {
|
||||
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $global:DbxBundleTestState.BundleDir 'dbx-test.exe'), 'fixture installer')
|
||||
}
|
||||
}
|
||||
function New-Fixture([int]$Failures, [bool]$EmitInstaller = $true) {
|
||||
$directory = Join-Path $testRoot ([Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
Set-Location -LiteralPath $directory
|
||||
$global:DbxBundleTestState = @{
|
||||
BundleDir = Join-Path $directory 'target/x86_64-win7-windows-msvc/release/bundle/nsis'
|
||||
Attempts = 0
|
||||
Commands = @()
|
||||
Failures = $Failures
|
||||
EmitInstaller = $EmitInstaller
|
||||
}
|
||||
$global:LASTEXITCODE = 0
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($failures in @(0, 2)) {
|
||||
New-Fixture $failures
|
||||
$hash = & $bundleScript -RetryDelaySeconds 0
|
||||
Assert-True ($global:DbxBundleTestState.Attempts -eq ($failures + 1)) 'Unexpected retry count'
|
||||
Assert-True ($hash.Algorithm -eq 'SHA256' -and $hash.Hash.Length -eq 64) 'Installer hash missing'
|
||||
foreach ($command in $global:DbxBundleTestState.Commands) {
|
||||
Assert-True ($command -eq 'tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json --config .github/fixtures/win7-nsis-zlib-config.json') 'Bundle command changed'
|
||||
}
|
||||
}
|
||||
|
||||
New-Fixture 99
|
||||
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $global:DbxBundleTestState.BundleDir 'old.exe'), 'stale installer')
|
||||
$failure = $null
|
||||
try { & $bundleScript -RetryDelaySeconds 0 } catch { $failure = $_ }
|
||||
Assert-True ($global:DbxBundleTestState.Attempts -eq 3) 'Persistent failure was not bounded'
|
||||
Assert-True ($failure -and $failure.ToString().Contains('failed after 3 attempts')) 'A stale installer hid the failed bundle'
|
||||
|
||||
New-Fixture 0 $false
|
||||
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
|
||||
$failure = $null
|
||||
try { & $bundleScript -RetryDelaySeconds 0 } catch { $failure = $_ }
|
||||
Assert-True ($failure -and $failure.ToString().Contains('Missing Windows 7 fixed-runtime installer')) 'Missing installer was accepted'
|
||||
Assert-True ($global:DbxBundleTestState.Attempts -eq 1) 'Successful bundling was retried'
|
||||
Write-Host 'Windows 7 bundling retry tests passed (4 scenarios).'
|
||||
} finally {
|
||||
Set-Location -LiteralPath $originalLocation.Path
|
||||
$resolvedRoot = (Resolve-Path -LiteralPath $testRoot).ProviderPath
|
||||
if ($resolvedRoot -ne $testRoot -or !$resolvedRoot.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Refusing to remove a fixture outside the temporary directory.'
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function changeBase({ eventName, before, root = process.cwd() }) {
|
||||
if (eventName === "pull_request") {
|
||||
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
})
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
|
||||
return commits[1];
|
||||
}
|
||||
if (!before || /^0+$/.test(before)) return null;
|
||||
if (!/^[0-9a-f]{40}$/.test(before)) throw new Error("Invalid push base SHA");
|
||||
return before;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const base = changeBase({ eventName: process.env.GITHUB_EVENT_NAME, before: process.env.BEFORE_SHA });
|
||||
if (base) console.log(base);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { changeBase } from "./ci-change-base.mjs";
|
||||
import { changedPaths, planCi } from "./ci-plan.mjs";
|
||||
|
||||
function mergeFixture(context) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-change-base-"));
|
||||
context.after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
const root = path.join(directory, "source");
|
||||
mkdirSync(root);
|
||||
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
|
||||
const write = (file, content) => {
|
||||
const filename = path.join(root, file);
|
||||
mkdirSync(path.dirname(filename), { recursive: true });
|
||||
writeFileSync(filename, content);
|
||||
};
|
||||
const commit = (message) => {
|
||||
git("add", ".");
|
||||
git("-c", "core.hooksPath=/dev/null", "commit", "-m", message);
|
||||
return git("rev-parse", "HEAD");
|
||||
};
|
||||
git("init", "--initial-branch=main");
|
||||
git("config", "user.email", "fixture@example.invalid");
|
||||
git("config", "user.name", "CI Fixture");
|
||||
write("crates/dbx-core/tests/old.rs", "original source\n");
|
||||
write("crates/dbx-core/tests/deleted.rs", "removed source\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "original route\n");
|
||||
const staleBase = commit("baseline");
|
||||
git("checkout", "-b", "pull-request");
|
||||
git("mv", "crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/renamed\nfile.rs");
|
||||
git("rm", "crates/dbx-core/tests/deleted.rs");
|
||||
write("crates/dbx-core/tests/runtime_diagnostic_history.rs", "diagnostic test\n");
|
||||
write("crates/dbx-web/src/routes/transfer.rs", "updated route\n");
|
||||
const pullRequestHead = commit("PR diagnostic changes");
|
||||
git("checkout", "main");
|
||||
write(".github/workflows/ci.yml", "main CI changes\n");
|
||||
write("agents/drivers/zookeeper/main.go", "main native Agent changes\n");
|
||||
write("agents/drivers/oceanbase-oracle/src/main/java/Agent.java", "main JDBC changes\n");
|
||||
const actualBase = commit("main advances while PR remains open");
|
||||
git("-c", "core.hooksPath=/dev/null", "merge", "--no-ff", "pull-request", "-m", "synthetic PR test merge");
|
||||
return { directory, root, staleBase, actualBase, pullRequestHead };
|
||||
}
|
||||
|
||||
test("PR routing uses the exact test merge first parent, excluding unrelated main changes", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const base = changeBase({ eventName: "pull_request", before: fixture.staleBase, root: fixture.root });
|
||||
assert.equal(base, fixture.actualBase);
|
||||
assert.notEqual(base, fixture.pullRequestHead);
|
||||
const files = changedPaths(base, fixture.root);
|
||||
assert.deepEqual(files.toSorted(), ["crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"].toSorted());
|
||||
const metadata = {
|
||||
workspace_members: ["dbx-core", "dbx-web", "dbx"],
|
||||
packages: [
|
||||
{ id: "dbx-core", name: "dbx-core", manifest_path: path.join(fixture.root, "crates/dbx-core/Cargo.toml"), dependencies: [] },
|
||||
{ id: "dbx-web", name: "dbx-web", manifest_path: path.join(fixture.root, "crates/dbx-web/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
{ id: "dbx", name: "dbx", manifest_path: path.join(fixture.root, "src-tauri/Cargo.toml"), dependencies: [{ name: "dbx-core" }] },
|
||||
],
|
||||
};
|
||||
const corrected = planCi({ files, metadata, root: fixture.root });
|
||||
assert.equal(corrected.rust_full, false);
|
||||
assert.deepEqual(
|
||||
corrected.rust_matrix.include.map((entry) => entry.group),
|
||||
["application"],
|
||||
);
|
||||
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(corrected.agent_java, false);
|
||||
assert.deepEqual(corrected.agent_go.include, []);
|
||||
assert.deepEqual(corrected.agent_integration.include, []);
|
||||
assert.equal(corrected.windows_standard, true);
|
||||
assert.equal(corrected.windows_win7_bundle, false);
|
||||
const staleFiles = changedPaths(fixture.staleBase, fixture.root);
|
||||
assert.ok(staleFiles.includes(".github/workflows/ci.yml"));
|
||||
assert.ok(staleFiles.includes("agents/drivers/zookeeper/main.go"));
|
||||
const polluted = planCi({ files: staleFiles, metadata, root: fixture.root });
|
||||
assert.equal(polluted.rust_full, true);
|
||||
assert.equal(polluted.agent_java, true);
|
||||
assert.ok(polluted.agent_go.include.length > 0);
|
||||
assert.ok(polluted.agent_integration.include.length > 0);
|
||||
});
|
||||
|
||||
test("a depth-two checkout preserves the PR parents and supports the base CLI", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-two");
|
||||
execFileSync("git", ["clone", "--depth=2", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
|
||||
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
|
||||
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
|
||||
cwd: checkout,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
|
||||
});
|
||||
assert.equal(output.trim(), fixture.actualBase);
|
||||
});
|
||||
|
||||
test("PR base detection rejects a nonmerge commit and a depth-one checkout", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
const checkout = path.join(fixture.directory, "depth-one");
|
||||
execFileSync("git", ["clone", "--depth=1", pathToFileURL(fixture.root).href, checkout], { stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: checkout }), /merge commit and both parents/);
|
||||
execFileSync("git", ["checkout", "--detach", fixture.pullRequestHead], { cwd: fixture.root, stdio: "pipe" });
|
||||
assert.throws(() => changeBase({ eventName: "pull_request", root: fixture.root }), /merge commit and both parents/);
|
||||
});
|
||||
|
||||
test("push routing preserves the event before SHA even when HEAD is a merge", (context) => {
|
||||
const fixture = mergeFixture(context);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.staleBase, root: fixture.root }), fixture.staleBase);
|
||||
assert.equal(changeBase({ eventName: "push", before: fixture.pullRequestHead, root: fixture.root }), fixture.pullRequestHead);
|
||||
for (const before of [undefined, "", "0".repeat(40)]) {
|
||||
assert.equal(changeBase({ eventName: "push", before }), null);
|
||||
}
|
||||
for (const before of ["not-a-ref", "HEAD", "a".repeat(39), "a".repeat(41)]) {
|
||||
assert.throws(() => changeBase({ eventName: "push", before }), /Invalid push base SHA/);
|
||||
}
|
||||
});
|
||||
@@ -1,14 +1,7 @@
|
||||
export const rustGroups = {
|
||||
foundation: [
|
||||
"dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql",
|
||||
"dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime",
|
||||
],
|
||||
drivers: [
|
||||
"dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
|
||||
"dbx-sqlite-worker",
|
||||
],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp"],
|
||||
foundation: ["dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"],
|
||||
drivers: ["dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers", "dbx-sqlite-worker"],
|
||||
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
};
|
||||
|
||||
export const goAgents = [
|
||||
@@ -32,7 +25,9 @@ export const integrationCases = [
|
||||
{ driver: "zookeeper", scenario: "zookeeper-sasl", version: "3.7.0" },
|
||||
...["4.9.8", "5.3.1"].map((version) => ({ driver: "rocketmq", scenario: "rocketmq", version })),
|
||||
...["2.4.0.14", "2.6.0.34", "3.0.7.1", "3.3.6.13", "3.4.2.2"].map((version) => ({
|
||||
driver: "tdengine", scenario: "tdengine", version,
|
||||
driver: "tdengine",
|
||||
scenario: "tdengine",
|
||||
version,
|
||||
image: `tdengine/${version === "3.4.2.2" ? "tsdb" : "tdengine"}:${version}`,
|
||||
})),
|
||||
...["3.11.19", "5.0.6"].map((version) => ({ driver: "cassandra-go", scenario: "cassandra", version })),
|
||||
|
||||
@@ -9,10 +9,24 @@ import { goAgents, integrationCases } from "./ci-config.mjs";
|
||||
import { lockfileManifests } from "./ci-lockfiles.mjs";
|
||||
|
||||
const root = fileURLToPath(new URL("../../", import.meta.url));
|
||||
const environmentKeys = ["CGO_ENABLED", "GOOS", "GOARCH", "GONOSUMDB", "DBX_ZOOKEEPER_TEST_CONNECT_STRING",
|
||||
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME", "DBX_ZOOKEEPER_TEST_USERNAME", "DBX_ZOOKEEPER_TEST_PASSWORD", "TDENGINE_INTEGRATION",
|
||||
"TDENGINE_TEST_HOST", "TDENGINE_TEST_PORT", "CASSANDRA_TEST_HOST", "CASSANDRA_TEST_PORT", "RABBITMQ_INTEGRATION",
|
||||
"RABBITMQ_USERNAME", "RABBITMQ_PASSWORD"];
|
||||
const environmentKeys = [
|
||||
"CGO_ENABLED",
|
||||
"GOOS",
|
||||
"GOARCH",
|
||||
"GONOSUMDB",
|
||||
"DBX_ZOOKEEPER_TEST_CONNECT_STRING",
|
||||
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME",
|
||||
"DBX_ZOOKEEPER_TEST_USERNAME",
|
||||
"DBX_ZOOKEEPER_TEST_PASSWORD",
|
||||
"TDENGINE_INTEGRATION",
|
||||
"TDENGINE_TEST_HOST",
|
||||
"TDENGINE_TEST_PORT",
|
||||
"CASSANDRA_TEST_HOST",
|
||||
"CASSANDRA_TEST_PORT",
|
||||
"RABBITMQ_INTEGRATION",
|
||||
"RABBITMQ_USERNAME",
|
||||
"RABBITMQ_PASSWORD",
|
||||
];
|
||||
|
||||
function withToolStubs(run, extraEnv = {}) {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-execution-"));
|
||||
@@ -20,7 +34,9 @@ function withToolStubs(run, extraEnv = {}) {
|
||||
mkdirSync(binaries);
|
||||
const log = path.join(directory, "commands.jsonl");
|
||||
const stub = path.join(binaries, "stub.cjs");
|
||||
writeFileSync(stub, `#!/usr/bin/env node
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const tool = path.basename(process.argv[1]);
|
||||
@@ -29,100 +45,131 @@ const env = Object.fromEntries(${JSON.stringify(environmentKeys)}.filter(name =>
|
||||
fs.appendFileSync(process.env.CI_STUB_LOG, JSON.stringify({tool,args,cwd:process.cwd(),env})+'\\n');
|
||||
if (process.env.CI_STUB_FAIL === tool) process.exit(23);
|
||||
if (tool === 'docker' && args.includes('taos')) console.log('| ready |');
|
||||
`, { mode: 0o755 });
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
for (const name of ["docker", "go", "cargo", "timeout", "curl", "sleep", "bash"]) symlinkSync(stub, path.join(binaries, name));
|
||||
try {
|
||||
const env = { ...process.env, ...extraEnv, TMPDIR: directory, CI_STUB_LOG: log, PATH: `${binaries}${path.delimiter}${process.env.PATH}` };
|
||||
return run(env, () => readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)));
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
return run(env, () =>
|
||||
readFileSync(log, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const agent of goAgents) {
|
||||
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () => withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const invocations = commands();
|
||||
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
|
||||
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
|
||||
const builds = invocations.slice(1);
|
||||
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
|
||||
for (const build of builds) {
|
||||
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
|
||||
assert.equal(build.env.CGO_ENABLED, "0");
|
||||
}
|
||||
if (agent.driver === "zookeeper") assert.deepEqual(builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
|
||||
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"]);
|
||||
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
|
||||
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
|
||||
}));
|
||||
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () =>
|
||||
withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const invocations = commands();
|
||||
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
|
||||
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
|
||||
const builds = invocations.slice(1);
|
||||
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
|
||||
for (const build of builds) {
|
||||
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
|
||||
assert.equal(build.env.CGO_ENABLED, "0");
|
||||
}
|
||||
if (agent.driver === "zookeeper")
|
||||
assert.deepEqual(
|
||||
builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
|
||||
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"],
|
||||
);
|
||||
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
|
||||
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
|
||||
}));
|
||||
}
|
||||
|
||||
for (const entry of integrationCases) {
|
||||
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () => withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const invocations = commands();
|
||||
if (entry.scenario === "rocketmq") {
|
||||
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
|
||||
return;
|
||||
}
|
||||
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
|
||||
assert.ok(start, "starts the selected container");
|
||||
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}`
|
||||
: entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
|
||||
assert.equal(start.args.at(-1), expectedImage);
|
||||
const last = invocations.at(-1);
|
||||
assert.equal(last.tool, "docker");
|
||||
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
|
||||
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
|
||||
assert.ok(tests.length);
|
||||
if (entry.scenario === "zookeeper") {
|
||||
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
|
||||
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
|
||||
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
|
||||
} else if (entry.scenario === "zookeeper-sasl") {
|
||||
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
|
||||
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
|
||||
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
|
||||
} else if (entry.scenario === "tdengine") {
|
||||
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
|
||||
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
|
||||
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
|
||||
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
|
||||
else {
|
||||
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
|
||||
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
|
||||
assert.ok(start.args.includes("999:999"));
|
||||
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
|
||||
}
|
||||
}));
|
||||
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () =>
|
||||
withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const invocations = commands();
|
||||
if (entry.scenario === "rocketmq") {
|
||||
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
|
||||
return;
|
||||
}
|
||||
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
|
||||
assert.ok(start, "starts the selected container");
|
||||
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}` : entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
|
||||
assert.equal(start.args.at(-1), expectedImage);
|
||||
const last = invocations.at(-1);
|
||||
assert.equal(last.tool, "docker");
|
||||
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
|
||||
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
|
||||
assert.ok(tests.length);
|
||||
if (entry.scenario === "zookeeper") {
|
||||
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
|
||||
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
|
||||
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
|
||||
} else if (entry.scenario === "zookeeper-sasl") {
|
||||
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
|
||||
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
|
||||
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
|
||||
} else if (entry.scenario === "tdengine") {
|
||||
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
|
||||
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
|
||||
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
|
||||
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
|
||||
else {
|
||||
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
|
||||
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
|
||||
assert.ok(start.args.includes("999:999"));
|
||||
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
test("failed live tests stay failed and still clean their container", () => withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 23);
|
||||
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
|
||||
}, { CI_STUB_FAIL: "go" }));
|
||||
test("failed live tests stay failed and still clean their container", () =>
|
||||
withToolStubs(
|
||||
(env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 23);
|
||||
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
|
||||
},
|
||||
{ CI_STUB_FAIL: "go" },
|
||||
));
|
||||
|
||||
test("failed nextest live tests propagate failure and still clean their container", () => withToolStubs((env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 23);
|
||||
assert.deepEqual(commands().find((command) => command.tool === "cargo").args.slice(0, 2), ["nextest", "run"]);
|
||||
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
|
||||
}, { CI_STUB_FAIL: "cargo" }));
|
||||
test("failed nextest live tests propagate failure and still clean their container", () =>
|
||||
withToolStubs(
|
||||
(env, commands) => {
|
||||
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, 23);
|
||||
assert.deepEqual(
|
||||
commands()
|
||||
.find((command) => command.tool === "cargo")
|
||||
.args.slice(0, 2),
|
||||
["nextest", "run"],
|
||||
);
|
||||
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
|
||||
},
|
||||
{ CI_STUB_FAIL: "cargo" },
|
||||
));
|
||||
|
||||
test("lock preflight resolves full graphs, keeps --locked and propagates failures", () => {
|
||||
const plan = { rust: true, agent_rust: { include: [{ driver: "duckdb" }, { driver: "tdengine" }] } };
|
||||
assert.deepEqual(lockfileManifests(plan), ["Cargo.toml", "agents/drivers/duckdb/Cargo.toml", "agents/drivers/tdengine/Cargo.toml"]);
|
||||
assert.throws(() => lockfileManifests({ rust: false, agent_rust: { include: [{ driver: "../../unknown" }] } }));
|
||||
for (const failure of ["", "cargo"]) withToolStubs((env, commands) => {
|
||||
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, failure ? 23 : 0, result.stderr);
|
||||
assert.equal(commands().length, failure ? 1 : 3);
|
||||
for (const command of commands()) {
|
||||
assert.equal(command.args[0], "metadata");
|
||||
assert.ok(command.args.includes("--locked"));
|
||||
assert.ok(!command.args.includes("--no-deps"));
|
||||
}
|
||||
}, { CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure });
|
||||
for (const failure of ["", "cargo"])
|
||||
withToolStubs(
|
||||
(env, commands) => {
|
||||
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
|
||||
assert.equal(result.status, failure ? 23 : 0, result.stderr);
|
||||
assert.equal(commands().length, failure ? 1 : 3);
|
||||
for (const command of commands()) {
|
||||
assert.equal(command.args[0], "metadata");
|
||||
assert.ok(command.args.includes("--locked"));
|
||||
assert.ok(!command.args.includes("--no-deps"));
|
||||
}
|
||||
},
|
||||
{ CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure },
|
||||
);
|
||||
});
|
||||
|
||||
+46
-18
@@ -4,33 +4,61 @@ import { pathToFileURL } from "node:url";
|
||||
export function gateFailures(needs, mode) {
|
||||
if (needs.changes?.result !== "success") return ["changes did not succeed"];
|
||||
let plan;
|
||||
try { plan = JSON.parse(needs.changes.outputs.plan); } catch { return ["missing or invalid CI plan"]; }
|
||||
try {
|
||||
plan = JSON.parse(needs.changes.outputs.plan);
|
||||
} catch {
|
||||
return ["missing or invalid CI plan"];
|
||||
}
|
||||
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return ["incomplete CI plan"];
|
||||
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
|
||||
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
const routedJobs = {
|
||||
frontend: "frontend",
|
||||
packages: "packages",
|
||||
"github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_standard",
|
||||
"windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows",
|
||||
jdbc: "jdbc",
|
||||
"offline-jdbc-release": "offline_jdbc",
|
||||
"nix-packaging": "nix",
|
||||
};
|
||||
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
|
||||
return ["missing or invalid job selection outputs"];
|
||||
}
|
||||
if (mode === "frontend" && !["true", "false"].includes(needs.changes.outputs.frontend)) {
|
||||
return ["missing or invalid frontend selection output"];
|
||||
}
|
||||
const expected = mode === "rust" ? {
|
||||
"fast-checks": plan.fast, "rust-fmt-clippy": plan.rust, "rust-test": plan.rust,
|
||||
} : mode === "agents" ? {
|
||||
"fast-checks": plan.fast, "agent-checks": plan.agents, "agent-java": plan.agent_java, "agent-go": plan.agent_go_changed,
|
||||
"agent-rust": plan.agent_rust_changed, "agent-integration": plan.agent_integration_changed,
|
||||
} : mode === "frontend" ? {
|
||||
"frontend-checks": needs.changes.outputs.frontend === "true",
|
||||
"frontend-typecheck": needs.changes.outputs.frontend === "true",
|
||||
"frontend-test": needs.changes.outputs.frontend === "true",
|
||||
} : mode === "all" ? {
|
||||
rust: true, agents: true, "fast-checks": plan.fast,
|
||||
...Object.fromEntries(Object.entries(routedJobs)
|
||||
.map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
|
||||
} : null;
|
||||
const expected =
|
||||
mode === "rust"
|
||||
? {
|
||||
"fast-checks": plan.fast,
|
||||
"rust-fmt-clippy": plan.rust,
|
||||
"rust-test": plan.rust,
|
||||
}
|
||||
: mode === "agents"
|
||||
? {
|
||||
"fast-checks": plan.fast,
|
||||
"agent-checks": plan.agents,
|
||||
"agent-java": plan.agent_java,
|
||||
"agent-go": plan.agent_go_changed,
|
||||
"agent-rust": plan.agent_rust_changed,
|
||||
"agent-integration": plan.agent_integration_changed,
|
||||
}
|
||||
: mode === "frontend"
|
||||
? {
|
||||
"frontend-checks": needs.changes.outputs.frontend === "true",
|
||||
"frontend-typecheck": needs.changes.outputs.frontend === "true",
|
||||
"frontend-test": needs.changes.outputs.frontend === "true",
|
||||
}
|
||||
: mode === "all"
|
||||
? {
|
||||
rust: true,
|
||||
agents: true,
|
||||
"fast-checks": plan.fast,
|
||||
...Object.fromEntries(Object.entries(routedJobs).map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
|
||||
}
|
||||
: null;
|
||||
if (!expected) return [`unknown gate: ${mode}`];
|
||||
return Object.entries(expected).flatMap(([job, required]) => {
|
||||
const result = needs[job]?.result;
|
||||
|
||||
+76
-11
@@ -2,19 +2,48 @@
|
||||
|
||||
Paths in this guide are relative to `.github/`.
|
||||
|
||||
`workflows/ci.yml` retains one workflow and one cancellation group. Splitting jobs
|
||||
does not change release workflows or branch-protection settings. The existing
|
||||
`workflows/ci.yml` retains one workflow. Splitting jobs does not change release
|
||||
workflows or branch-protection settings. The existing
|
||||
`rust` and `agents` aggregate check names remain; `ci` additionally summarizes all
|
||||
selected jobs. None of these gates accepts a failed, cancelled, missing, or
|
||||
unexpectedly skipped prerequisite. The gates are guarded by
|
||||
`if: always() && !cancelled()`: on a superseded commit a newer push cancels the
|
||||
run, and `always()` alone would still execute the gate and report those
|
||||
cancellations as a failure of the commit that no longer matters.
|
||||
`if: always() && !cancelled()`: on a cancelled run, `always()` alone would still
|
||||
execute the gate and report cancelled prerequisites as a failure.
|
||||
|
||||
## Contributor concurrency
|
||||
|
||||
PRs from anyone other than the repository owner share main `CI` concurrency
|
||||
groups keyed by the PR author's stable numeric user ID and the parity of the PR
|
||||
number (`pr-author-<id>-even` / `pr-author-<id>-odd`). At most two runs per author
|
||||
execute at a time — one per parity group — and other authors have independent
|
||||
groups. Actions expressions have no modulo, so parity is read from the PR number's
|
||||
last digit with `endsWith`. Two PRs of the same parity still queue behind each other.
|
||||
The PR author, not the trigger actor or author association, determines this
|
||||
policy, so a maintainer retrigger cannot bypass contributor limits.
|
||||
`cancel-in-progress: false` preserves the executing PR, and `queue: max` retains
|
||||
up to 100 pending runs rather than replacing earlier pending PRs. Further runs
|
||||
beyond that limit are cancelled by GitHub. Updates to the same contributor PR
|
||||
also enqueue separate runs; this policy does not deduplicate superseded commits.
|
||||
|
||||
The repository owner (`t8y2` here, compared with `github.repository_owner`) is
|
||||
exempt from author-level limits. Owner PRs retain one cancellation group per PR,
|
||||
so separate owner PRs run independently and a new commit cancels that PR's old
|
||||
run. Main pushes retain their independent ref-based cancellation group. These
|
||||
paths use `queue: single`, never `queue: max` with in-progress cancellation.
|
||||
|
||||
This limits main CI workflow runs, not the runner count inside one selected PR.
|
||||
Other PR workflows and all release workflows remain unchanged. Existing runs
|
||||
retain their original workflow configuration until a new event uses this policy.
|
||||
|
||||
## Selection
|
||||
|
||||
`scripts/ci-plan.mjs` reads Cargo workspace metadata and the exact event base/head
|
||||
diff. Renames include both paths and deleted paths remain visible. Rust package
|
||||
`scripts/ci-change-base.mjs` uses the first parent of the checked-out PR test merge
|
||||
as its base, not the potentially stale base SHA in the PR event. A depth-two
|
||||
checkout preserves both parents; a missing merge parent fails change detection.
|
||||
Pushes use the exact event `before` SHA. `paths-filter` uses Git, with an empty API
|
||||
token and explicit base/head, so its selection matches `scripts/ci-plan.mjs`
|
||||
rather than a mutable PR file list. An unavailable push diff keeps conservative
|
||||
planner coverage. Renames include both paths and deleted paths remain visible. Rust package
|
||||
selection follows reverse dependencies, including build and test dependencies.
|
||||
Connection-type descriptors belong to `dbx-types`; dialect descriptors belong to
|
||||
`dbx-sql`. A changed core dependency also selects the standalone DuckDB Agent tests,
|
||||
@@ -22,7 +51,10 @@ whose development dependency is `dbx-core`.
|
||||
|
||||
- Ordinary Rust PRs select foundation, driver, and application test groups from
|
||||
`scripts/ci-config.mjs`. Each group contains several packages, not one runner per
|
||||
crate. At most three Rust test groups run concurrently.
|
||||
crate. The application group includes `dbx-tauri-consul` and `dbx-tauri-schema`.
|
||||
At most three Rust test groups run concurrently; a contract checks the groups
|
||||
against actual workspace metadata so extracted crates cannot silently force
|
||||
every PR into the full-workspace fallback.
|
||||
- Main pushes, shared Cargo manifests/locks, toolchain/config/vendor changes, CI
|
||||
infrastructure changes, an unknown workspace member, or an unavailable diff
|
||||
select the original full-workspace test lane instead of duplicating it with all
|
||||
@@ -34,8 +66,33 @@ whose development dependency is `dbx-core`.
|
||||
cases. Shared Agent inputs conservatively select all existing Agent checks.
|
||||
Windows DuckDB packaging is selected by its own inputs or shared build/lock
|
||||
changes, not every change to its development dependency's source.
|
||||
- Existing frontend, package, Windows, JDBC, offline-payload and Nix jobs retain
|
||||
their commands and path filters.
|
||||
- Standard Windows compilation follows desktop reverse dependencies, including
|
||||
ordinary core/driver source changes. Full Win7 packaging runs for Cargo
|
||||
manifests/locks, toolchain/config/vendor changes, platform/update/installer
|
||||
code, Tauri configuration, Win7 fixtures/scripts and release/CI workflow inputs.
|
||||
Unknown Rust paths, unassigned Rust workspace members and unavailable diffs
|
||||
retain both Windows jobs. Other desktop source and test changes keep standard
|
||||
Windows compilation without repeating the full Win7 installer build. The final
|
||||
gate validates each Windows selection independently; release builds are unchanged.
|
||||
- Existing frontend, package, JDBC, offline-payload and Nix jobs retain their
|
||||
commands and path filters, except for the snapshot-only routing below.
|
||||
|
||||
Contributor snapshots refresh weekly, at 02:00 Monday in Asia/Shanghai, and can
|
||||
still be refreshed manually. An unchanged snapshot keeps its existing timestamp
|
||||
and creates no commit. A diff containing only `docs/data/contributors.json` skips
|
||||
desktop frontend validation, but the docs deployment still rebuilds the site.
|
||||
Mixed diffs and unavailable diffs keep the existing validation routing.
|
||||
|
||||
When `fast-checks` is selected, it owns the CI planner and gate tests before
|
||||
expensive dependent jobs start. `github-scripts` omits that subset in these runs,
|
||||
but runs every GitHub script test when the fast-check lane is not selected.
|
||||
|
||||
Issue command and spam jobs reject irrelevant comments before allocating a
|
||||
runner. Their existing command parser and malicious-link matcher remain the
|
||||
authoritative checks. PR notification triggers are commented out; release
|
||||
notifications in the publishing workflow are unchanged. Plugin development
|
||||
host lint and formatting run once on Linux before the Linux, macOS and Windows
|
||||
runtime, Rust and package checks; cross-platform coverage is unchanged.
|
||||
|
||||
Changes to the planner itself select the broad checks. Missing/invalid planning
|
||||
data fails the aggregate gates rather than producing a successful skip.
|
||||
@@ -70,7 +127,9 @@ incompatible SQLite backends.
|
||||
| `agent-integration` | Sixteen existing engine/version/authentication cases | 4 |
|
||||
|
||||
Matrix jobs set `fail-fast: false`, so one failure does not conceal results from
|
||||
the other selected cases. Live test predicates, readiness checks, time limits,
|
||||
the other selected cases. Go and live matrix parallelism is bounded at four each
|
||||
to reduce bursts competing with other workflows; this is not a reservation or a
|
||||
priority guarantee for release jobs. Live test predicates, readiness checks, time limits,
|
||||
credentials for disposable fixtures, and failure cleanup are retained.
|
||||
Native, Java and live jobs wait for fast checks and Agent source validation before
|
||||
starting expensive builds or containers.
|
||||
@@ -104,6 +163,12 @@ successful builds would otherwise keep restoring the unfinished snapshot.
|
||||
The first successful main run must populate the new namespace before fork PRs can
|
||||
reuse it. Target caching is not a per-commit snapshot of workspace artifacts.
|
||||
|
||||
The Win7 job looks up AWS-LC and OpenSSL artifacts from main runs.
|
||||
Each artifact name includes the fixture hash and the pinned nightly toolchain.
|
||||
If an artifact is absent or expires, the job builds that dependency.
|
||||
Main uploads each new artifact for 30 days. PRs do not upload copies.
|
||||
Run `node .github/scripts/find-win7-prebuilt-artifact.mjs --help` for lookup usage.
|
||||
|
||||
Clippy, nextest and native Agent nextest builds emit Cargo HTML timing reports.
|
||||
Their `DBX-*-cargo-timings` artifacts are retained for three days, including after
|
||||
failures when a report exists, but uploads do not delay cancelled runs. Inspect
|
||||
|
||||
@@ -3,11 +3,13 @@ import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function lockfileManifests(plan) {
|
||||
return [...(plan.rust ? ["Cargo.toml"] : []),
|
||||
return [
|
||||
...(plan.rust ? ["Cargo.toml"] : []),
|
||||
...plan.agent_rust.include.map(({ driver }) => {
|
||||
if (!["duckdb", "tdengine"].includes(driver)) throw new Error(`Unknown native Rust driver: ${driver}`);
|
||||
return `agents/drivers/${driver}/Cargo.toml`;
|
||||
})];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
|
||||
+59
-46
@@ -16,14 +16,7 @@ const win7InfrastructureFiles = new Set([
|
||||
"src-tauri/tauri.webview2-win7-fixed.conf.json",
|
||||
"src-tauri/build.rs",
|
||||
]);
|
||||
const win7InfrastructurePrefixes = [
|
||||
"src-tauri/windows/nsis/",
|
||||
"vendor/wry/",
|
||||
"vendor/webview2-com-sys/",
|
||||
"vendor/ctor/",
|
||||
"vendor/dirs-sys/",
|
||||
"vendor/pageant/",
|
||||
];
|
||||
const win7InfrastructurePrefixes = [".cargo/", "rust-toolchain", ".github/fixtures/win7-", "src-tauri/windows/nsis/", "src-tauri/src/commands/update", "crates/dbx-core/src/host/update", "crates/dbx-platform/", "vendor/"];
|
||||
|
||||
function expandAffectedPackages(packages, members, affected) {
|
||||
let expanded;
|
||||
@@ -42,17 +35,19 @@ export function changedPaths(base, root) {
|
||||
if (!base || /^0+$/.test(base)) return null;
|
||||
execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd: root, stdio: "pipe" });
|
||||
return execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, "HEAD", "--"], {
|
||||
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
||||
}).split("\0").filter(Boolean);
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
})
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function knownJavaDrivers(root, nativeDrivers) {
|
||||
try {
|
||||
const driversRoot = path.join(root, "agents/drivers");
|
||||
return readdirSync(driversRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory()
|
||||
&& existsSync(path.join(driversRoot, entry.name, "build.gradle"))
|
||||
&& !nativeDrivers.includes(entry.name))
|
||||
.filter((entry) => entry.isDirectory() && existsSync(path.join(driversRoot, entry.name, "build.gradle")) && !nativeDrivers.includes(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
} catch {
|
||||
@@ -61,9 +56,13 @@ export function knownJavaDrivers(root, nativeDrivers) {
|
||||
}
|
||||
|
||||
export function cargoMetadata(root, execute = execFileSync) {
|
||||
return JSON.parse(execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
|
||||
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
|
||||
}));
|
||||
return JSON.parse(
|
||||
execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) {
|
||||
@@ -73,8 +72,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
const members = new Map(packages.map((pkg) => [pkg.name, pkg]));
|
||||
const packagePaths = packages.map((pkg) => [pkg.name, `${path.relative(root, path.dirname(pkg.manifest_path)).split(path.sep).join("/")}/`]);
|
||||
const ciChanged = unknownDiff || files.some((file) => /^\.github\/(?:workflows\/ci(?:[.-]|\/)|scripts\/ci-|actions\/ci-)/.test(file));
|
||||
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file)
|
||||
|| /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file) || /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const affected = new Set();
|
||||
let unknownRust = false;
|
||||
for (const file of files) {
|
||||
@@ -92,31 +90,34 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
expandAffectedPackages(packages, members, win7Affected);
|
||||
if (full) for (const pkg of packages) affected.add(pkg.name);
|
||||
expandAffectedPackages(packages, members, affected);
|
||||
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|
||||
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)));
|
||||
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|
||||
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file) || win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)) || /^src-tauri\/tauri.*\.json$/.test(file));
|
||||
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock") || files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
|
||||
const windowsWin7Reasons = {
|
||||
infrastructure: win7InfrastructureChanged,
|
||||
dependency_input: win7DependencyChanged,
|
||||
desktop_dependency: win7Affected.has("dbx"),
|
||||
unknown_rust: unknownRust,
|
||||
unknown_member: rust && unknownMember,
|
||||
};
|
||||
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
|
||||
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
|
||||
.filter(([, names]) => names.some((name) => affected.has(name)))
|
||||
.map(([group]) => ({ group }));
|
||||
const rustMatrix = !rust
|
||||
? []
|
||||
: full
|
||||
? [{ group: "workspace" }]
|
||||
: Object.entries(rustGroups)
|
||||
.filter(([, names]) => names.some((name) => affected.has(name)))
|
||||
.map(([group]) => ({ group }));
|
||||
const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents];
|
||||
const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers);
|
||||
const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))));
|
||||
const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)));
|
||||
// Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown
|
||||
// agents/drivers/<name>/ paths still fail open to full coverage below.
|
||||
const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/")
|
||||
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))
|
||||
&& !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|
||||
|| files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml"
|
||||
|| file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
|
||||
const sharedAgents =
|
||||
ciChanged ||
|
||||
sharedRust ||
|
||||
files.some((file) => file.startsWith("agents/") && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) ||
|
||||
files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" || file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
|
||||
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges);
|
||||
if (allAgents) {
|
||||
for (const driver of nativeDrivers) nativeChanges.add(driver);
|
||||
@@ -128,13 +129,24 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
|
||||
const java = allAgents || javaDriverChanges;
|
||||
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
|
||||
return {
|
||||
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
|
||||
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
|
||||
rust,
|
||||
rust_full: full,
|
||||
rust_matrix: { include: rustMatrix },
|
||||
rust_groups_known: !unknownMember,
|
||||
affected_packages: [...affected].sort(),
|
||||
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
|
||||
agent_integration: { include: liveMatrix }, agent_go_changed: goMatrix.length > 0,
|
||||
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
|
||||
agents,
|
||||
agent_java: java,
|
||||
agent_go: { include: goMatrix },
|
||||
agent_rust: { include: nativeRustMatrix },
|
||||
agent_integration: { include: liveMatrix },
|
||||
agent_go_changed: goMatrix.length > 0,
|
||||
agent_rust_changed: nativeRustMatrix.length > 0,
|
||||
agent_integration_changed: liveMatrix.length > 0,
|
||||
duckdb_changed: nativeChanges.has("duckdb"),
|
||||
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
|
||||
windows_standard: windowsWin7Candidate,
|
||||
windows_win7_bundle: win7InfrastructureChanged || win7DependencyChanged || unknownRust || (rust && unknownMember),
|
||||
windows_win7_candidate: windowsWin7Candidate,
|
||||
windows_win7_affected_packages: [...win7Affected].sort(),
|
||||
windows_win7_reasons: windowsWin7Reasons,
|
||||
@@ -146,20 +158,21 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
|
||||
const root = process.cwd();
|
||||
const metadata = cargoMetadata(root);
|
||||
const plan = planCi({
|
||||
files: changedPaths(process.env.BASE_SHA, root), metadata, root, eventName: process.env.GITHUB_EVENT_NAME,
|
||||
rustChanged: process.env.RUST_CHANGED === "true", agentsChanged: process.env.AGENTS_CHANGED === "true",
|
||||
files: changedPaths(process.env.BASE_SHA, root),
|
||||
metadata,
|
||||
root,
|
||||
eventName: process.env.GITHUB_EVENT_NAME,
|
||||
rustChanged: process.env.RUST_CHANGED === "true",
|
||||
agentsChanged: process.env.AGENTS_CHANGED === "true",
|
||||
});
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
const outputs = { ...plan, plan };
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
Object.entries(outputs)
|
||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}\n`)
|
||||
.join(""),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
...plan,
|
||||
win7_routing_comparison: {
|
||||
current: process.env.WIN7_CURRENT === "true",
|
||||
candidate: plan.windows_win7_candidate,
|
||||
affected_packages: plan.windows_win7_affected_packages,
|
||||
reasons: plan.windows_win7_reasons,
|
||||
},
|
||||
}, null, 2));
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
}
|
||||
|
||||
@@ -12,11 +12,16 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs";
|
||||
|
||||
const root = "/fixture";
|
||||
const graph = {
|
||||
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"],
|
||||
"dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-types": [],
|
||||
"dbx-platform": [],
|
||||
"dbx-formats": [],
|
||||
"dbx-sql-core": ["dbx-types"],
|
||||
"dbx-sql-dialect": ["dbx-types"],
|
||||
"dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
|
||||
"dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"],
|
||||
"dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
|
||||
"dbx-ai-provider": ["dbx-platform"],
|
||||
"dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
|
||||
"dbx-driver-support": ["dbx-platform", "dbx-types"],
|
||||
"dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"],
|
||||
"dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"],
|
||||
@@ -25,16 +30,22 @@ const graph = {
|
||||
"dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"],
|
||||
"dbx-driver-redis": ["dbx-driver-support", "dbx-types"],
|
||||
"dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"],
|
||||
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
|
||||
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
|
||||
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
|
||||
"dbx-sqlite-worker": [],
|
||||
"dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support", "dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
|
||||
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
|
||||
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp"],
|
||||
"dbx-mcp": ["dbx-core"],
|
||||
"dbx-cli": ["dbx-core", "dbx-mcp"],
|
||||
"dbx-web": ["dbx-core", "dbx-mcp"],
|
||||
dbx: ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
|
||||
"dbx-tauri-consul": ["dbx-core"],
|
||||
"dbx-tauri-schema": ["dbx-core"],
|
||||
};
|
||||
const metadata = {
|
||||
workspace_members: Object.keys(graph),
|
||||
packages: Object.entries(graph).map(([name, dependencies]) => ({
|
||||
id: name, name, manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
|
||||
id: name,
|
||||
name,
|
||||
manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
|
||||
dependencies: dependencies.map((dependency) => ({ name: dependency })),
|
||||
})),
|
||||
};
|
||||
@@ -52,11 +63,26 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
|
||||
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
|
||||
assert.equal(invocation.options.cwd, "/workspace");
|
||||
});
|
||||
// Keep the gate fixtures in sync with ci-gate.mjs routedJobs. Both Windows jobs
|
||||
// share the windows_win7_bundle routing output.
|
||||
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_win7_bundle", "windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
|
||||
const routedJobs = {
|
||||
frontend: "frontend",
|
||||
packages: "packages",
|
||||
"github-scripts": "github_scripts",
|
||||
"windows-standard-check": "windows_standard",
|
||||
"windows-win7-bundle": "windows_win7_bundle",
|
||||
"duckdb-windows-driver": "duckdb_windows",
|
||||
jdbc: "jdbc",
|
||||
"offline-jdbc-release": "offline_jdbc",
|
||||
"nix-packaging": "nix",
|
||||
};
|
||||
|
||||
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
|
||||
for (const eventName of ["pull_request", "push"]) {
|
||||
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
|
||||
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"], ["docs/data/contributors.json", "apps/desktop/src/main.ts"], ["docs/data/contributors.json", "docs/lib/contributorActivity.ts"], ["docs/data/contributors.json", "Cargo.lock"]]) {
|
||||
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("foundation changes select transitive consumers and standalone DuckDB", () => {
|
||||
const result = plan(["crates/dbx-types/src/lib.rs"]);
|
||||
@@ -85,13 +111,30 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
|
||||
assert.equal(plan(["crates/dbx-cli/src/main.rs"]).windows_win7_candidate, false);
|
||||
});
|
||||
|
||||
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
|
||||
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
|
||||
for (const file of [
|
||||
"src-tauri/src/commands/update.rs",
|
||||
"crates/dbx-core/src/host/update.rs",
|
||||
"crates/dbx-core/src/query/mod.rs",
|
||||
"crates/dbx-core/tests/runtime_diagnostic_history.rs",
|
||||
"crates/dbx-drivers/src/lib.rs",
|
||||
"crates/dbx-driver-postgres/src/postgres.rs",
|
||||
"crates/dbx-platform/src/lib.rs",
|
||||
"crates/dbx-driver-mysql/tests/query.rs",
|
||||
"src-tauri/src/commands/query.rs",
|
||||
"crates/dbx-tauri-consul/src/lib.rs",
|
||||
"crates/dbx-tauri-schema/src/lib.rs",
|
||||
]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
for (const file of ["crates/dbx-cli/src/main.rs", "crates/dbx-web/src/routes/transfer.rs", "docs/README.md"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_standard, false, file);
|
||||
assert.equal(result.windows_win7_bundle, false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
|
||||
for (const file of ["src-tauri/src/commands/update.rs", "crates/dbx-core/src/host/update.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-platform/src/lib.rs"]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
}
|
||||
});
|
||||
@@ -114,20 +157,45 @@ test("Win7 infrastructure changes remain candidates outside the Cargo graph", ()
|
||||
"vendor/ctor/src/lib.rs",
|
||||
"vendor/dirs-sys/src/lib.rs",
|
||||
"vendor/pageant/src/lib.rs",
|
||||
".cargo/config.toml",
|
||||
"rust-toolchain.toml",
|
||||
".github/fixtures/win7-imports/dll.json",
|
||||
"src-tauri/tauri.conf.json",
|
||||
"src-tauri/tauri.windows.conf.json",
|
||||
"src-tauri/src/commands/update.rs",
|
||||
"crates/dbx-core/src/host/update.rs",
|
||||
"crates/dbx-platform/src/lib.rs",
|
||||
]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
const result = plan([file]);
|
||||
assert.equal(result.windows_win7_candidate, true, file);
|
||||
assert.equal(result.windows_standard, true, file);
|
||||
assert.equal(result.windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("Win7 dependency inputs fail open", () => {
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", "src-tauri/Cargo.toml", "crates/dbx-cli/Cargo.toml"]) {
|
||||
assert.equal(plan([file]).windows_win7_candidate, true, file);
|
||||
assert.equal(plan([file]).windows_standard, true, file);
|
||||
assert.equal(plan([file]).windows_win7_bundle, true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown Rust changes and unknown diffs remain Win7 candidates", () => {
|
||||
assert.equal(plan(["crates/new-engine/src/lib.rs"]).windows_win7_candidate, true);
|
||||
assert.equal(plan(null).windows_win7_candidate, true);
|
||||
for (const files of [["crates/new-engine/src/lib.rs"], null]) {
|
||||
const result = plan(files);
|
||||
assert.equal(result.windows_win7_candidate, true);
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
}
|
||||
const fixture = structuredClone(metadata);
|
||||
fixture.workspace_members.push("new-engine");
|
||||
fixture.packages.push({ id: "new-engine", name: "new-engine", manifest_path: `${root}/crates/new-engine/Cargo.toml`, dependencies: [] });
|
||||
const result = plan(["crates/dbx-web/src/lib.rs"], { metadata: fixture });
|
||||
assert.equal(result.windows_standard, true);
|
||||
assert.equal(result.windows_win7_bundle, true);
|
||||
assert.equal(result.windows_win7_reasons.unknown_member, true);
|
||||
assert.equal(plan(["docs/README.md"], { metadata: fixture }).windows_win7_bundle, false);
|
||||
});
|
||||
|
||||
test("unrelated CI inputs do not become Win7 candidates through full Rust coverage", () => {
|
||||
@@ -152,6 +220,7 @@ test("Win7 candidate reports each routing reason", () => {
|
||||
dependency_input: false,
|
||||
desktop_dependency: false,
|
||||
unknown_rust: false,
|
||||
unknown_member: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,8 +232,7 @@ test("code generation and test-only dependencies participate in impact analysis"
|
||||
assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types"));
|
||||
});
|
||||
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs",
|
||||
"crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
|
||||
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
|
||||
test(`shared input ${file} retains full workspace and Agent coverage`, () => {
|
||||
const result = plan([file]);
|
||||
assert.deepEqual(groups(result), ["workspace"]);
|
||||
@@ -223,8 +291,7 @@ test("known JDBC driver changes only select Java agent tests", () => {
|
||||
});
|
||||
|
||||
test("shared Agent inputs and unknown native modules never silently lose coverage", () => {
|
||||
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle",
|
||||
"agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
|
||||
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", "agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
|
||||
const result = plan([file]);
|
||||
assert.equal(result.agent_go.include.length, goAgents.length, file);
|
||||
assert.equal(result.agent_integration.include.length, 16, file);
|
||||
@@ -237,13 +304,32 @@ test("shared Agent inputs and unknown native modules never silently lose coverag
|
||||
});
|
||||
|
||||
test("the independent live matrix preserves every old version and authentication case", () => {
|
||||
assert.deepEqual(integrationCases.map((entry) => `${entry.scenario}:${entry.version}`), [
|
||||
"zookeeper:3.4.14", "zookeeper:3.5.5", "zookeeper:3.7.0", "zookeeper:3.9.5", "zookeeper-sasl:3.7.0",
|
||||
"rocketmq:4.9.8", "rocketmq:5.3.1", "tdengine:2.4.0.14", "tdengine:2.6.0.34", "tdengine:3.0.7.1",
|
||||
"tdengine:3.3.6.13", "tdengine:3.4.2.2", "cassandra:3.11.19", "cassandra:5.0.6", "rabbitmq:3.13", "rabbitmq:4.3",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
integrationCases.map((entry) => `${entry.scenario}:${entry.version}`),
|
||||
[
|
||||
"zookeeper:3.4.14",
|
||||
"zookeeper:3.5.5",
|
||||
"zookeeper:3.7.0",
|
||||
"zookeeper:3.9.5",
|
||||
"zookeeper-sasl:3.7.0",
|
||||
"rocketmq:4.9.8",
|
||||
"rocketmq:5.3.1",
|
||||
"tdengine:2.4.0.14",
|
||||
"tdengine:2.6.0.34",
|
||||
"tdengine:3.0.7.1",
|
||||
"tdengine:3.3.6.13",
|
||||
"tdengine:3.4.2.2",
|
||||
"cassandra:3.11.19",
|
||||
"cassandra:5.0.6",
|
||||
"rabbitmq:3.13",
|
||||
"rabbitmq:4.3",
|
||||
],
|
||||
);
|
||||
assert.equal(integrationCases.find((entry) => entry.version === "3.4.2.2").image, "tdengine/tsdb:3.4.2.2");
|
||||
assert.deepEqual(goAgents.filter((entry) => entry.race).map((entry) => entry.driver), ["rocketmq", "zookeeper"]);
|
||||
assert.deepEqual(
|
||||
goAgents.filter((entry) => entry.race).map((entry) => entry.driver),
|
||||
["rocketmq", "zookeeper"],
|
||||
);
|
||||
});
|
||||
|
||||
test("test groups partition every current workspace package exactly once", () => {
|
||||
@@ -252,6 +338,21 @@ test("test groups partition every current workspace package exactly once", () =>
|
||||
assert.deepEqual(selected.toSorted(), Object.keys(graph).toSorted());
|
||||
});
|
||||
|
||||
test("Rust groups cover the actual workspace metadata, including extracted Tauri crates", () => {
|
||||
const repository = path.resolve(import.meta.dirname, "../..");
|
||||
const actual = cargoMetadata(repository);
|
||||
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
|
||||
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
|
||||
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"], metadata: actual, root: repository });
|
||||
assert.equal(result.rust_groups_known, true);
|
||||
assert.equal(result.rust_full, false);
|
||||
assert.deepEqual(groups(result), ["application"]);
|
||||
assert.deepEqual(result.agent_rust.include, [{ driver: "duckdb" }]);
|
||||
assert.equal(result.agent_java, false);
|
||||
assert.equal(result.agent_go.include.length, 0);
|
||||
assert.equal(result.agent_integration.include.length, 0);
|
||||
});
|
||||
|
||||
test("workspace Cargo flags preserve full versus fast coverage and strict clippy", () => {
|
||||
for (const mode of ["fast", "full"]) {
|
||||
const command = rustCommand("test", "workspace", mode);
|
||||
@@ -293,13 +394,17 @@ test("nextest, doctests and coverage select identical packages and features in e
|
||||
|
||||
function results(files) {
|
||||
const selected = plan(files);
|
||||
return { changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
|
||||
return {
|
||||
changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
|
||||
"fast-checks": { result: selected.fast ? "success" : "skipped" },
|
||||
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" }, "rust-test": { result: selected.rust ? "success" : "skipped" },
|
||||
"agent-checks": { result: selected.agents ? "success" : "skipped" }, "agent-java": { result: selected.agent_java ? "success" : "skipped" },
|
||||
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" },
|
||||
"rust-test": { result: selected.rust ? "success" : "skipped" },
|
||||
"agent-checks": { result: selected.agents ? "success" : "skipped" },
|
||||
"agent-java": { result: selected.agent_java ? "success" : "skipped" },
|
||||
"agent-go": { result: selected.agent_go_changed ? "success" : "skipped" },
|
||||
"agent-rust": { result: selected.agent_rust_changed ? "success" : "skipped" },
|
||||
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" } };
|
||||
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" },
|
||||
};
|
||||
}
|
||||
|
||||
test("gates accept only successful selected jobs and intentionally skipped unselected jobs", () => {
|
||||
@@ -342,17 +447,23 @@ test("git diff routing includes both sides of renames, deleted files, and unusua
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-plan-"));
|
||||
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: "pipe" }).trim();
|
||||
try {
|
||||
git("init"); git("config", "user.email", "fixture@example.invalid"); git("config", "user.name", "CI Fixture");
|
||||
git("init");
|
||||
git("config", "user.email", "fixture@example.invalid");
|
||||
git("config", "user.name", "CI Fixture");
|
||||
writeFileSync(path.join(directory, "old.rs"), "unchanged source\n");
|
||||
writeFileSync(path.join(directory, "deleted.rs"), "gone\n");
|
||||
git("add", "."); git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
|
||||
git("add", ".");
|
||||
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
|
||||
const base = git("rev-parse", "HEAD");
|
||||
git("mv", "old.rs", "renamed\nfile.rs"); git("rm", "deleted.rs");
|
||||
git("mv", "old.rs", "renamed\nfile.rs");
|
||||
git("rm", "deleted.rs");
|
||||
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture change");
|
||||
assert.deepEqual(changedPaths(base, directory).toSorted(), ["old.rs", "deleted.rs", "renamed\nfile.rs"].toSorted());
|
||||
assert.equal(changedPaths("0".repeat(40), directory), null);
|
||||
assert.throws(() => changedPaths("not-a-ref", directory));
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the final gate rejects absent routing outputs and skipped selected jobs", () => {
|
||||
@@ -382,6 +493,7 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
needs.changes.outputs[output] = "false";
|
||||
}
|
||||
needs.changes.outputs.windows_win7_bundle = "true";
|
||||
needs.changes.outputs.windows_standard = "true";
|
||||
needs["windows-standard-check"].result = "success";
|
||||
needs["windows-win7-bundle"].result = "success";
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
@@ -393,9 +505,28 @@ test("the final gate requires both Windows jobs when the bundle routing is selec
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("the final gate independently requires standard Windows compilation without Win7 packaging", () => {
|
||||
const needs = results(["crates/dbx-core/src/query/mod.rs"]);
|
||||
const selected = plan(["crates/dbx-core/src/query/mod.rs"]);
|
||||
needs.rust = needs.agents = { result: "success" };
|
||||
for (const [job, output] of Object.entries(routedJobs)) {
|
||||
needs.changes.outputs[output] = String(selected[output] ?? false);
|
||||
needs[job] = { result: selected[output] ? "success" : "skipped" };
|
||||
}
|
||||
assert.equal(needs.changes.outputs.windows_standard, "true");
|
||||
assert.equal(needs.changes.outputs.windows_win7_bundle, "false");
|
||||
assert.deepEqual(gateFailures(needs, "all"), []);
|
||||
for (const result of ["failure", "skipped", "cancelled"]) {
|
||||
needs["windows-standard-check"].result = result;
|
||||
assert.ok(gateFailures(needs, "all").some((failure) => failure.includes("windows-standard-check")));
|
||||
}
|
||||
needs["windows-standard-check"].result = "success";
|
||||
delete needs.changes.outputs.windows_standard;
|
||||
assert.ok(gateFailures(needs, "all").length);
|
||||
});
|
||||
|
||||
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
|
||||
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
|
||||
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
|
||||
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])), ...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
|
||||
assertCoverage(coverage);
|
||||
const lost = structuredClone(coverage);
|
||||
lost.foundation["dbx-types"] = [];
|
||||
|
||||
@@ -6,11 +6,17 @@ import { rustGroups } from "./ci-config.mjs";
|
||||
import { rustCommand } from "./ci-rust.mjs";
|
||||
|
||||
export function parseCoverage(output) {
|
||||
return Object.fromEntries(output.trim().split("\n").filter(Boolean).map((line) => {
|
||||
const [name, features] = line.split("|");
|
||||
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
|
||||
return [name.split(" ")[0], features.split(",").filter(Boolean)];
|
||||
}));
|
||||
return Object.fromEntries(
|
||||
output
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, features] = line.split("|");
|
||||
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
|
||||
return [name.split(" ")[0], features.split(",").filter(Boolean)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCoverage(coverage) {
|
||||
|
||||
+30
-13
@@ -4,26 +4,43 @@ import { pathToFileURL } from "node:url";
|
||||
import { rustGroups } from "./ci-config.mjs";
|
||||
|
||||
export function rustCommand(action, group, mode) {
|
||||
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode)
|
||||
|| (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
|
||||
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode) || (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
|
||||
throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`);
|
||||
}
|
||||
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"];
|
||||
if (mode === "full") capabilityFeatures.push("system-fonts");
|
||||
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
|
||||
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
|
||||
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
|
||||
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
|
||||
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
|
||||
"dbx-driver-support/test-support",
|
||||
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
|
||||
] : appFeatures;
|
||||
const foundationFeatures = [
|
||||
"dbx-types/mq-admin",
|
||||
"dbx-types/openapi",
|
||||
"dbx-sql/duckdb-sidecar",
|
||||
"dbx-sql/openapi",
|
||||
"dbx-sql-data/openapi",
|
||||
"dbx-sql-schema/duckdb-sidecar",
|
||||
"dbx-platform/downloads",
|
||||
"dbx-platform/host-prompts",
|
||||
"dbx-platform/test-support",
|
||||
"dbx-plugin-runtime/default",
|
||||
"dbx-plugin-runtime/test-support",
|
||||
];
|
||||
const features =
|
||||
group === "foundation"
|
||||
? foundationFeatures
|
||||
: group === "drivers"
|
||||
? [
|
||||
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
|
||||
"dbx-driver-agent/test-support",
|
||||
"dbx-driver-mysql/test-support",
|
||||
"dbx-driver-redis/test-support",
|
||||
"dbx-driver-support/test-support",
|
||||
"dbx-sqlite-worker/runtime",
|
||||
"dbx-types/openapi",
|
||||
"dbx-sql-data/openapi",
|
||||
]
|
||||
: appFeatures;
|
||||
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
|
||||
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
|
||||
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []),
|
||||
"--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
|
||||
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []), "--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
|
||||
@@ -29,10 +29,13 @@ test("file timing reports sort total work and keep timing phases", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })), [
|
||||
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
|
||||
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })),
|
||||
[
|
||||
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
|
||||
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
|
||||
],
|
||||
);
|
||||
assert.match(consoleReport(rows, 1), /0\.20s slow\.spec\.ts/);
|
||||
assert.doesNotMatch(consoleReport(rows, 1), /fast\.spec\.ts/);
|
||||
assert.match(markdownReport(rows, "1\/2"), /Vitest file timings \(1\/2\)/);
|
||||
|
||||
@@ -15,6 +15,65 @@ function job(name, content = workflow) {
|
||||
return jobs.slice(definitions[index].index, definitions[index + 1]?.index ?? jobs.length);
|
||||
}
|
||||
|
||||
function concurrencySettings(github) {
|
||||
const block = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)?.[1];
|
||||
assert.ok(block, "missing workflow concurrency");
|
||||
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
|
||||
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
|
||||
return Object.fromEntries(
|
||||
block
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
|
||||
const evaluate = (expression) => new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
|
||||
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
|
||||
return [setting, expressions.length === 1 && expressions[0][0] === value ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
|
||||
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
|
||||
}
|
||||
|
||||
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
|
||||
const even = concurrencySettings(concurrencyContext());
|
||||
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
|
||||
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
|
||||
assert.deepEqual(concurrencySettings(context), even);
|
||||
}
|
||||
for (const number of [101, 11849, 11853, 7]) {
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ number })), { ...even, group: "CI-pr-author-101-odd" }, String(number));
|
||||
}
|
||||
for (const number of [2, 4, 6, 8, 11850]) assert.equal(concurrencySettings(concurrencyContext({ number })).group, even.group);
|
||||
assert.notEqual(concurrencySettings(concurrencyContext({ authorId: 102, author: "another-contributor" })).group, even.group);
|
||||
});
|
||||
|
||||
test("repository-owner PRs remain independent and superseded runs are cancelled only within a PR", () => {
|
||||
const first = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2" }));
|
||||
const second = concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", ref: "refs/pull/200/merge" }));
|
||||
assert.deepEqual(first, { group: "CI-refs/pull/100/merge", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(first.group, second.group);
|
||||
assert.deepEqual(concurrencySettings(concurrencyContext({ authorId: 1, author: "t8y2", actor: "another-contributor" })), first);
|
||||
});
|
||||
|
||||
test("main pushes retain their independent cancellation group without PR context", () => {
|
||||
const main = concurrencySettings(concurrencyContext({ eventName: "push", ref: "refs/heads/main", actor: "github-actions[bot]" }));
|
||||
assert.deepEqual(main, { group: "CI-refs/heads/main", "cancel-in-progress": true, queue: "single" });
|
||||
assert.notEqual(main.group, concurrencySettings(concurrencyContext()).group);
|
||||
});
|
||||
|
||||
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
|
||||
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }), concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
|
||||
const settings = concurrencySettings(context);
|
||||
assert.ok(settings.queue === "single" || settings.queue === "max");
|
||||
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
|
||||
}
|
||||
const concurrency = workflow.match(/^concurrency:\n((?: [^\n]+\n)+)/m)[1];
|
||||
assert.doesNotMatch(concurrency, /github\.actor|author_association/);
|
||||
});
|
||||
|
||||
test("fast checks run format and contracts before graph resolution or compilation", () => {
|
||||
const fast = job("fast-checks");
|
||||
for (const command of ["cargo fmt --check", "node --test scripts/core-architecture.test.mjs", "node scripts/sync-connection-types.mjs --check", "node .github/scripts/ci-lockfiles.mjs", "node .github/scripts/ci-rust-coverage.mjs"]) assert.ok(fast.includes(command));
|
||||
@@ -23,12 +82,31 @@ test("fast checks run format and contracts before graph resolution or compilatio
|
||||
assert.ok(fast.includes("needs.changes.outputs.rust_groups_known == 'true'"));
|
||||
});
|
||||
|
||||
test("fast checks retain ownership of the planner subset before expensive jobs", () => {
|
||||
assert.match(job("github-scripts"), /FAST_CHECKS: \$\{\{ needs\.changes\.outputs\.fast \}\}/);
|
||||
assert.match(job("github-scripts"), /for file in \.github\/scripts\/\*\.test\.mjs/);
|
||||
assert.match(job("fast-checks"), /name: CI planner and gate tests\s+run: node --test \.github\/scripts\/ci-\*\.test\.mjs/);
|
||||
});
|
||||
|
||||
test("contributor snapshot routing preserves docs deployment and mixed frontend validation", () => {
|
||||
assert.ok(job("changes").includes("frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}"));
|
||||
assert.match(job("changes"), /frontend:\s+- 'apps\/desktop\/\*\*'\s+- 'docs\/\*\*'/);
|
||||
const docsWorkflow = readFileSync(new URL("../workflows/docs.yml", import.meta.url), "utf8");
|
||||
assert.match(docsWorkflow, /paths: \["docs\/\*\*"\]/);
|
||||
for (const file of ["contributors", "issue-claim", "notify", "plugin-dev-host", "spam-comment-guard"]) {
|
||||
assert.ok(job("changes").includes(`- '.github/workflows/${file}.yml'`));
|
||||
}
|
||||
for (const file of ["contributor-snapshot", "update-contributors"]) {
|
||||
assert.ok(job("changes").includes(`- 'docs/scripts/${file}.mjs'`));
|
||||
}
|
||||
});
|
||||
|
||||
test("Agent and Rust matrices are bounded and do not cancel sibling failures", () => {
|
||||
for (const [name, output, parallel] of [
|
||||
["rust-test", "rust_matrix", 3],
|
||||
["agent-rust", "agent_rust", 2],
|
||||
["agent-go", "agent_go", 8],
|
||||
["agent-integration", "agent_integration", 8],
|
||||
["agent-go", "agent_go", 4],
|
||||
["agent-integration", "agent_integration", 4],
|
||||
]) {
|
||||
const content = job(name);
|
||||
assert.match(content, /fail-fast: false/);
|
||||
@@ -69,6 +147,21 @@ test("frontend tests use two shards on separate runners", () => {
|
||||
assert.doesNotMatch(job("frontend-typecheck"), /vitest|oxfmt|oxlint/);
|
||||
});
|
||||
|
||||
test("frontend type checking retains its configured heap budget", () => {
|
||||
const content = job("frontend-typecheck");
|
||||
assert.match(content, /NODE_OPTIONS: --max-old-space-size=8192/);
|
||||
assert.match(content, /name: Type-check frontend\s+run: pnpm exec vue-tsc --noEmit --project apps\/desktop\/tsconfig\.json/);
|
||||
});
|
||||
|
||||
test("Windows 7 bundling uses the tested retry script and still audits the installer", () => {
|
||||
const content = job("windows-win7-bundle");
|
||||
assert.ok(content.includes("run: ./.github/scripts/bundle-win7-installer.ps1"));
|
||||
assert.doesNotMatch(content, /bundle-win7-installer\.test\.ps1/);
|
||||
assert.ok(content.includes("assert-win7-installer-content.ps1"));
|
||||
assert.ok(content.indexOf("bundle-win7-installer.ps1") < content.indexOf("assert-win7-installer-content.ps1"));
|
||||
assert.ok(job("github-scripts").includes("run: ./.github/scripts/bundle-win7-installer.test.ps1"));
|
||||
});
|
||||
|
||||
test("every old Agent stage has an independent owner and Java packaging remains strict", () => {
|
||||
assert.ok(job("agent-checks").includes("python3 -m unittest discover"));
|
||||
assert.ok(job("agent-checks").includes("python3 scripts/validate_agents.py"));
|
||||
@@ -124,7 +217,11 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
|
||||
const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1];
|
||||
assert.ok(setup, name);
|
||||
assert.doesNotMatch(setup, /^\s+if:/m);
|
||||
const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n");
|
||||
const script = setup
|
||||
.match(/ run: \|\n([\s\S]*)/)[1]
|
||||
.split("\n")
|
||||
.map((line) => line.slice(10))
|
||||
.join("\n");
|
||||
for (const remote of [true, false]) {
|
||||
const output = path.join(directory, `${name}-${remote}.env`);
|
||||
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
|
||||
@@ -143,16 +240,25 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, "");
|
||||
const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}));
|
||||
assert.deepEqual(exported, {
|
||||
...(remote ? backend : {}),
|
||||
SCCACHE_IDLE_TIMEOUT: "0",
|
||||
CC: `${compiler} cc`,
|
||||
CXX: `${compiler} c++`,
|
||||
}, `${name}: ${remote ? "S3" : "local"}`);
|
||||
const exported = Object.fromEntries(
|
||||
readFileSync(output, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const separator = line.indexOf("=");
|
||||
return [line.slice(0, separator), line.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
exported,
|
||||
{
|
||||
...(remote ? backend : {}),
|
||||
SCCACHE_IDLE_TIMEOUT: "0",
|
||||
CC: `${compiler} cc`,
|
||||
CXX: `${compiler} c++`,
|
||||
},
|
||||
`${name}: ${remote ? "S3" : "local"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -188,7 +294,7 @@ test("Rust test jobs install pinned nextest and retain separate doctests", () =>
|
||||
const pluginRelease = readFileSync(new URL("../workflows/plugin-cli-release.yml", import.meta.url), "utf8");
|
||||
const consumers = [job("packages"), job("rust-test"), job("agent-rust"), job("agent-integration"), job("test", pluginDevHost), job("prepare", pluginRelease)];
|
||||
for (const content of consumers) {
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3"));
|
||||
assert.ok(content.includes("uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b"));
|
||||
assert.ok(content.includes("tool: cargo-nextest@0.9.137"));
|
||||
assert.doesNotMatch(content, /cargo test (?!.*--doc)/);
|
||||
const testCommand = content.indexOf("cargo nextest run") >= 0 ? "cargo nextest run" : content.includes("pnpm test:packages") ? "pnpm test:packages" : content.includes("ci-rust.mjs test") ? "ci-rust.mjs test" : "ci-agent-integration.sh";
|
||||
@@ -215,7 +321,8 @@ test("local Rust entrypoints and live TDengine tests use nextest", () => {
|
||||
|
||||
test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
|
||||
const content = job("duckdb-windows-driver");
|
||||
assert.ok(content.includes('SCCACHE_GHA_ENABLED: "true"'));
|
||||
// S3 is preferred over the quota-limited GHA backend; GHA remains the fallback.
|
||||
assert.ok(content.includes("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}"));
|
||||
assert.ok(content.includes('CC: "sccache cl.exe"'));
|
||||
assert.ok(content.includes('CXX: "sccache cl.exe"'));
|
||||
assert.ok(content.includes("0b201ec74fa43914dc39ae48a89fd1d8cb592756"));
|
||||
@@ -224,11 +331,10 @@ test("DuckDB Windows builds persist Rust and C++ compiler results", () => {
|
||||
});
|
||||
|
||||
test("Windows compatibility jobs cache Rust compilation without wrapping C or C++", () => {
|
||||
assert.ok(job("changes").includes("'vendor/webview2-com-sys/**'"));
|
||||
const standard = job("windows-standard-check");
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.ok(standard.includes("needs.changes.outputs.windows_standard == 'true'"));
|
||||
assert.ok(standard.includes("RUSTC_WRAPPER: sccache"));
|
||||
assert.ok(standard.includes('SCCACHE_GHA_ENABLED: "true"'));
|
||||
assert.ok(standard.includes("SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}"));
|
||||
assert.ok(standard.includes("SCCACHE_GHA_VERSION: windows-standard-v1"));
|
||||
assert.ok(standard.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
|
||||
assert.ok(standard.includes('version: "v0.16.0"'));
|
||||
@@ -236,8 +342,9 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.ok(standard.includes("sccache --show-stats"));
|
||||
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("needs.changes.outputs.windows_win7_bundle == 'true'"));
|
||||
assert.doesNotMatch(win7, /x86_64-pc-windows-msvc|Setup Rust for standard Windows/);
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", 'SCCACHE_GHA_ENABLED: "true"', "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
|
||||
for (const setting of ["RUSTC_WRAPPER: sccache", "SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}", "SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1", 'SCCACHE_IDLE_TIMEOUT: "0"', 'CARGO_PROFILE_RELEASE_LTO: "off"', 'CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"']) assert.ok(win7.includes(setting));
|
||||
assert.ok(win7.includes("fc920bf0ec8de6ee65d409111f7ec508035751ba"));
|
||||
assert.ok(win7.includes('version: "v0.16.0"'));
|
||||
assert.ok(win7.includes("sccache --show-stats"));
|
||||
@@ -245,7 +352,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.ok(win7.includes("--config .github/fixtures/win7-host-repro-config.toml"));
|
||||
const hostConfig = readFileSync(new URL("../fixtures/win7-host-repro-config.toml", import.meta.url), "utf8");
|
||||
assert.ok(hostConfig.includes("target-applies-to-host = false"));
|
||||
assert.ok(hostConfig.includes('[host.x86_64-pc-windows-msvc]'));
|
||||
assert.ok(hostConfig.includes("[host.x86_64-pc-windows-msvc]"));
|
||||
assert.ok(hostConfig.includes('rustflags = ["-Clink-arg=/Brepro"]'));
|
||||
assert.ok(win7.includes("--timings"));
|
||||
assert.ok(win7.includes("name: DBX-win7-cargo-timings"));
|
||||
@@ -269,27 +376,43 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
|
||||
assert.doesNotMatch(win7, /^\s+(?:CC|CXX):/m);
|
||||
});
|
||||
|
||||
test("Win7 TLS cache keys ignore the workspace lockfile", () => {
|
||||
test("Win7 TLS artifact names ignore the workspace lockfile", () => {
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("hashFiles('.github/fixtures/win7-aws-lc-cache/Cargo.toml', '.github/fixtures/win7-aws-lc-cache/Cargo.lock')"));
|
||||
assert.ok(win7.includes("hashFiles('.github/fixtures/win7-openssl-cache/Cargo.toml', '.github/fixtures/win7-openssl-cache/Cargo.lock')"));
|
||||
assert.doesNotMatch(win7, /key: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
|
||||
assert.doesNotMatch(win7, /WIN7_PREBUILT_ARTIFACT_NAME: win7-(?:aws-lc|openssl).*hashFiles\('Cargo\.lock'/);
|
||||
});
|
||||
|
||||
test("the planner uses the exact event base and preserves a single workflow cancellation scope", () => {
|
||||
test("Win7 TLS artifacts come from main and retain a build fallback", () => {
|
||||
const win7 = job("windows-win7-bundle");
|
||||
assert.ok(win7.includes("actions: read"));
|
||||
assert.doesNotMatch(win7, /uses: actions\/cache(?:\/restore|\/save)?@v4/);
|
||||
for (const [name, id] of [["AWS-LC", "aws-lc-artifact"], ["OpenSSL", "openssl-artifact"]]) {
|
||||
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id != ''`));
|
||||
assert.ok(win7.includes(`if: steps.${id}.outputs.run_id == ''`));
|
||||
assert.ok(win7.includes(`if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.${id}.outputs.run_id == ''`));
|
||||
assert.ok(win7.includes(`name: \${{ steps.${id}.outputs.name }}`));
|
||||
assert.ok(win7.indexOf(`name: Find prebuilt Win7 ${name} artifact`) < win7.indexOf(`name: Download prebuilt Win7 ${name} artifact`));
|
||||
assert.ok(win7.indexOf(`name: Download prebuilt Win7 ${name} artifact`) < win7.indexOf(`name: Build prebuilt Win7 ${name}`));
|
||||
assert.ok(win7.indexOf(`name: Build prebuilt Win7 ${name}`) < win7.indexOf(`name: Upload prebuilt Win7 ${name} on main`));
|
||||
assert.ok(win7.indexOf(`name: Configure prebuilt Win7 ${name}`) < win7.indexOf(`name: Upload prebuilt Win7 ${name} on main`));
|
||||
}
|
||||
});
|
||||
|
||||
test("path filtering and the planner use the exact test merge first parent", () => {
|
||||
const changes = job("changes");
|
||||
assert.ok(changes.includes("github.event.pull_request.base.sha || github.event.before"));
|
||||
assert.ok(changes.includes("BEFORE_SHA: ${{ github.event.before }}"));
|
||||
assert.ok(changes.includes('BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"'));
|
||||
assert.ok(changes.includes("fetch-depth: 2"));
|
||||
assert.ok(changes.includes('git fetch --no-tags --depth=1 origin "$BASE_SHA"'));
|
||||
assert.ok(changes.includes("base: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.match(changes, /with:\s+token: ''\s+base: \$\{\{ steps\.change-base\.outputs\.sha \}\}\s+ref: \$\{\{ github\.sha \}\}/);
|
||||
assert.ok(changes.includes("BASE_SHA: ${{ steps.change-base.outputs.sha }}"));
|
||||
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
|
||||
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
|
||||
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
|
||||
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
|
||||
"windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration", "windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
|
||||
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
|
||||
}
|
||||
assert.ok(changes.includes("WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}"));
|
||||
assert.ok(workflow.includes("group: ${{ github.workflow }}-${{ github.ref }}"));
|
||||
assert.ok(workflow.includes("cancel-in-progress: true"));
|
||||
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
|
||||
});
|
||||
|
||||
@@ -33,9 +33,7 @@ function parseArgs(argv) {
|
||||
}
|
||||
if (!args.token) throw new Error("CNB_TOKEN is required.");
|
||||
if (!args.currentTag || !args.tagPattern || !Number.isInteger(args.retain) || args.retain < 1) {
|
||||
throw new Error(
|
||||
"Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]",
|
||||
);
|
||||
throw new Error("Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]");
|
||||
}
|
||||
try {
|
||||
new RegExp(args.tagPattern);
|
||||
|
||||
@@ -17,18 +17,11 @@ const appReleases = [
|
||||
];
|
||||
|
||||
test("retention only removes old releases from the selected family", () => {
|
||||
const plan = planReleaseRetention(
|
||||
[
|
||||
release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"),
|
||||
release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"),
|
||||
...appReleases.toReversed(),
|
||||
],
|
||||
{
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
},
|
||||
);
|
||||
const plan = planReleaseRetention([release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"), release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"), ...appReleases.toReversed()], {
|
||||
currentTag: "v0.5.69",
|
||||
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
|
||||
retain: 5,
|
||||
});
|
||||
|
||||
assert.equal(plan.skipped, "");
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add Google's public Docker Hub cache without replacing daemon settings."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MIRROR = "https://mirror.gcr.io"
|
||||
|
||||
|
||||
def configure(path, validate=None):
|
||||
path = Path(path)
|
||||
config = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError("Docker daemon config must be a JSON object")
|
||||
mirrors = config.get("registry-mirrors", [])
|
||||
if not isinstance(mirrors, list) or any(not isinstance(item, str) for item in mirrors):
|
||||
raise ValueError("registry-mirrors must be an array of strings")
|
||||
if any(item.rstrip("/") == MIRROR for item in mirrors):
|
||||
return
|
||||
config["registry-mirrors"] = [*mirrors, MIRROR]
|
||||
original_stat = path.stat() if path.exists() else None
|
||||
candidate = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
||||
prefix="daemon-mirror-", suffix=".json", delete=False) as output:
|
||||
candidate = Path(output.name)
|
||||
output.write(json.dumps(config, indent=2) + "\n")
|
||||
candidate.chmod(stat.S_IMODE(original_stat.st_mode) if original_stat else 0o644)
|
||||
if original_stat and hasattr(os, "chown"):
|
||||
os.chown(candidate, original_stat.st_uid, original_stat.st_gid)
|
||||
if validate:
|
||||
validate(candidate)
|
||||
if original_stat:
|
||||
backup = path.with_name(path.name + ".before-dbx-mirror")
|
||||
if not backup.exists():
|
||||
descriptor = os.open(backup, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IMODE(original_stat.st_mode))
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(path.read_bytes())
|
||||
shutil.copystat(path, backup)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(backup, original_stat.st_uid, original_stat.st_gid)
|
||||
os.replace(candidate, path)
|
||||
finally:
|
||||
if candidate and candidate.exists():
|
||||
candidate.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("config", type=Path)
|
||||
parser.add_argument("--validate", action="store_true", help="Validate with dockerd before replacing config")
|
||||
args = parser.parse_args()
|
||||
|
||||
def validate(candidate):
|
||||
subprocess.run(["dockerd", "--validate", "--config-file", str(candidate)], check=True, timeout=15)
|
||||
|
||||
configure(args.config, validate if args.validate else None)
|
||||
@@ -0,0 +1,115 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"docker_mirror", Path(__file__).with_name("configure-docker-mirror.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class DockerMirrorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / "daemon.json"
|
||||
|
||||
def test_missing_config(self):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(json.loads(self.path.read_text()), {"registry-mirrors": [module.MIRROR]})
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_preserves_settings_and_existing_mirror_order(self):
|
||||
original = {"log-driver": "json-file", "features": {"containerd-snapshotter": True},
|
||||
"registry-mirrors": ["https://existing.example", "https://second.example/"]}
|
||||
self.path.write_text(json.dumps(original))
|
||||
module.configure(self.path)
|
||||
expected = {**original, "registry-mirrors": [*original["registry-mirrors"], module.MIRROR]}
|
||||
self.assertEqual(json.loads(self.path.read_text()), expected)
|
||||
|
||||
def test_second_run_does_not_write(self):
|
||||
module.configure(self.path)
|
||||
before = self.path.read_bytes()
|
||||
modified = self.path.stat().st_mtime_ns
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(self.path.stat().st_mtime_ns, modified)
|
||||
|
||||
def test_existing_mirror_with_trailing_slash_does_not_write(self):
|
||||
before = '{"registry-mirrors": ["https://mirror.gcr.io/"], "debug": false}\n'
|
||||
self.path.write_text(before)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_text(), before)
|
||||
|
||||
def test_invalid_json_fails_without_writing(self):
|
||||
before = b'{"debug":'
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_invalid_structure_fails_without_writing(self):
|
||||
for value in [[], {"registry-mirrors": "https://existing.example"},
|
||||
{"registry-mirrors": [None]}]:
|
||||
with self.subTest(value=value):
|
||||
before = json.dumps(value).encode()
|
||||
self.path.write_bytes(before)
|
||||
with self.assertRaises(ValueError):
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_existing_file_permissions_preserved(self):
|
||||
self.path.write_text('{"debug": false}')
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
self.assertEqual(self.path.stat().st_mode, mode)
|
||||
|
||||
def test_backup_preserves_original_bytes_and_mode(self):
|
||||
before = b'{ "debug": false, "log-driver": "json-file" }\n'
|
||||
self.path.write_bytes(before)
|
||||
self.path.chmod(0o600)
|
||||
mode = self.path.stat().st_mode
|
||||
module.configure(self.path)
|
||||
backup = self.path.with_name(self.path.name + ".before-dbx-mirror")
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
self.assertEqual(backup.stat().st_mode, mode)
|
||||
module.configure(self.path)
|
||||
self.assertEqual(backup.read_bytes(), before)
|
||||
|
||||
def test_validator_failure_preserves_original_and_removes_candidate(self):
|
||||
before = b'{"debug": false}\n'
|
||||
self.path.write_bytes(before)
|
||||
|
||||
def reject(candidate):
|
||||
self.assertEqual(json.loads(candidate.read_text())["registry-mirrors"], [module.MIRROR])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
raise ValueError("daemon validation failed")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "daemon validation failed"):
|
||||
module.configure(self.path, reject)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertEqual(list(self.path.parent.iterdir()), [self.path])
|
||||
|
||||
def test_validation_precedes_atomic_replace(self):
|
||||
before = b'{"debug": true}\n'
|
||||
self.path.write_bytes(before)
|
||||
candidates = []
|
||||
|
||||
def validate(candidate):
|
||||
self.assertEqual(candidate.parent, self.path.parent)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertTrue(json.loads(candidate.read_text())["debug"])
|
||||
candidates.append(candidate)
|
||||
|
||||
module.configure(self.path, validate)
|
||||
self.assertEqual(len(candidates), 1)
|
||||
self.assertFalse(candidates[0].exists())
|
||||
self.assertIn(module.MIRROR, json.loads(self.path.read_text())["registry-mirrors"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,36 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
const extraAliases = {
|
||||
mysql: [
|
||||
"mariadb",
|
||||
"percona",
|
||||
"tidb",
|
||||
"greatsql",
|
||||
"oceanbase mysql",
|
||||
"oceanbase mysql mode",
|
||||
"oceanbase mysql 模式",
|
||||
"tdsql mysql",
|
||||
"tdsql-mysql",
|
||||
"polardb mysql",
|
||||
"polardb for mysql",
|
||||
"polardb-x",
|
||||
"polardbx",
|
||||
],
|
||||
postgres: [
|
||||
"postgresql",
|
||||
"postgres",
|
||||
"pgsql",
|
||||
"pg",
|
||||
"hologres",
|
||||
"cloudberry",
|
||||
"apache cloudberry",
|
||||
"cockroachdb",
|
||||
"cockroach db",
|
||||
"polardb postgresql",
|
||||
"polardb for postgresql",
|
||||
"polardb postgres",
|
||||
"polardb for postgres",
|
||||
],
|
||||
mysql: ["mariadb", "percona", "tidb", "greatsql", "oceanbase mysql", "oceanbase mysql mode", "oceanbase mysql 模式", "tdsql mysql", "tdsql-mysql", "polardb mysql", "polardb for mysql", "polardb-x", "polardbx"],
|
||||
postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres", "cloudberry", "apache cloudberry", "cockroachdb", "cockroach db", "polardb postgresql", "polardb for postgresql", "polardb postgres", "polardb for postgres"],
|
||||
sqlite: ["sqlite3", "sql lite"],
|
||||
clickhouse: ["click house", "ch"],
|
||||
sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"],
|
||||
@@ -47,7 +19,7 @@ const extraAliases = {
|
||||
saphana: ["hana", "sap hana"],
|
||||
opengauss: ["open gauss"],
|
||||
"oceanbase-oracle": ["oceanbase oracle", "oceanbase oracle mode", "oceanbase oracle 模式"],
|
||||
gbase: ["gbase 8a", "gbase8a", "gbase 8s", "gbase8s"],
|
||||
gbase: ["gbase 8a", "gbase8a", "gbase 8c", "gbase8c", "gbase 8s", "gbase8s"],
|
||||
access: ["microsoft access", "ms access"],
|
||||
vastbase: ["vastbase g", "vastbaseg"],
|
||||
prestosql: ["presto", "presto sql"],
|
||||
@@ -104,9 +76,7 @@ const catalogEntries = [
|
||||
}),
|
||||
...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)),
|
||||
];
|
||||
const duplicateDbTypes = catalogEntries
|
||||
.map((driver) => driver.dbType)
|
||||
.filter((dbType, index, values) => values.indexOf(dbType) !== index);
|
||||
const duplicateDbTypes = catalogEntries.map((driver) => driver.dbType).filter((dbType, index, values) => values.indexOf(dbType) !== index);
|
||||
|
||||
if (duplicateDbTypes.length > 0) {
|
||||
throw new Error(`Duplicate database issue catalog entries: ${[...new Set(duplicateDbTypes)].join(", ")}`);
|
||||
@@ -115,12 +85,5 @@ if (duplicateDbTypes.length > 0) {
|
||||
export const databaseIssueDrivers = catalogEntries.map((driver) => ({
|
||||
dbType: driver.dbType,
|
||||
label: driver.label,
|
||||
aliases: [
|
||||
...new Set([
|
||||
driver.dbType,
|
||||
driver.label,
|
||||
...(driver.aliases || []),
|
||||
...(extraAliases[driver.dbType] || []),
|
||||
]),
|
||||
],
|
||||
aliases: [...new Set([driver.dbType, driver.label, ...(driver.aliases || []), ...(extraAliases[driver.dbType] || [])])],
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export function selectMainArtifact(artifacts, name, repositoryId) {
|
||||
return artifacts
|
||||
.filter((artifact) =>
|
||||
artifact.name === name &&
|
||||
!artifact.expired &&
|
||||
artifact.workflow_run?.head_branch === "main" &&
|
||||
artifact.workflow_run?.repository_id === repositoryId &&
|
||||
artifact.workflow_run?.head_repository_id === repositoryId
|
||||
)
|
||||
.sort((left, right) => Date.parse(right.created_at) - Date.parse(left.created_at))[0];
|
||||
}
|
||||
|
||||
export async function findMainArtifact({ name, repository, repositoryId, token, request = fetch }) {
|
||||
const url = new URL(`https://api.github.com/repos/${repository}/actions/artifacts`);
|
||||
url.searchParams.set("name", name);
|
||||
url.searchParams.set("per_page", "100");
|
||||
const response = await request(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub artifact lookup returned HTTP ${response.status}`);
|
||||
const result = await response.json();
|
||||
return selectMainArtifact(result.artifacts ?? [], name, repositoryId);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
||||
console.log("Usage: WIN7_PREBUILT_ARTIFACT_NAME=NAME node find-win7-prebuilt-artifact.mjs");
|
||||
return;
|
||||
}
|
||||
|
||||
const name = process.env.WIN7_PREBUILT_ARTIFACT_NAME;
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
const repositoryId = Number(process.env.GITHUB_REPOSITORY_ID);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const output = process.env.GITHUB_OUTPUT;
|
||||
if (!name || !repository || !Number.isSafeInteger(repositoryId) || !token || !output) {
|
||||
throw new Error("Win7 artifact lookup requires a name, repository, repository ID, token, and output path.");
|
||||
}
|
||||
|
||||
let runId = "";
|
||||
try {
|
||||
const artifact = await findMainArtifact({ name, repository, repositoryId, token });
|
||||
runId = artifact ? String(artifact.workflow_run.id) : "";
|
||||
} catch (error) {
|
||||
console.warn(`Win7 artifact lookup failed. The job will build locally. ${error.message}`);
|
||||
}
|
||||
|
||||
appendFileSync(output, `name=${name}\nrun_id=${runId}\n`);
|
||||
console.log(runId ? `Found main artifact from run ${runId}.` : "No main artifact found. The job will build locally.");
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { findMainArtifact, selectMainArtifact } from "./find-win7-prebuilt-artifact.mjs";
|
||||
|
||||
const repositoryId = 1224172037;
|
||||
const name = "win7-aws-lc-test";
|
||||
const artifact = (id, branch, headRepositoryId = repositoryId) => ({
|
||||
name,
|
||||
expired: false,
|
||||
created_at: `2026-10-17T00:00:0${id}Z`,
|
||||
workflow_run: {
|
||||
id,
|
||||
head_branch: branch,
|
||||
repository_id: repositoryId,
|
||||
head_repository_id: headRepositoryId,
|
||||
},
|
||||
});
|
||||
|
||||
test("artifact lookup ignores PR and fork artifacts", () => {
|
||||
const selected = selectMainArtifact([
|
||||
artifact(1, "main"),
|
||||
artifact(2, "feature"),
|
||||
artifact(3, "main", 999),
|
||||
{ ...artifact(4, "main"), expired: true },
|
||||
artifact(5, "main"),
|
||||
], name, repositoryId);
|
||||
assert.equal(selected.workflow_run.id, 5);
|
||||
});
|
||||
|
||||
test("artifact lookup requests the exact name", async () => {
|
||||
const selected = await findMainArtifact({
|
||||
name,
|
||||
repository: "t8y2/dbx",
|
||||
repositoryId,
|
||||
token: "synthetic",
|
||||
request: async (url, options) => {
|
||||
assert.equal(url.searchParams.get("name"), name);
|
||||
assert.equal(options.headers.Authorization, "Bearer synthetic");
|
||||
return { ok: true, json: async () => ({ artifacts: [artifact(6, "main")] }) };
|
||||
},
|
||||
});
|
||||
assert.equal(selected.workflow_run.id, 6);
|
||||
});
|
||||
|
||||
test("artifact lookup reports API errors", async () => {
|
||||
await assert.rejects(
|
||||
findMainArtifact({
|
||||
name,
|
||||
repository: "t8y2/dbx",
|
||||
repositoryId,
|
||||
token: "synthetic",
|
||||
request: async () => ({ ok: false, status: 503 }),
|
||||
}),
|
||||
/HTTP 503/,
|
||||
);
|
||||
});
|
||||
|
||||
test("artifact lookup has a help command", () => {
|
||||
const script = fileURLToPath(new URL("./find-win7-prebuilt-artifact.mjs", import.meta.url));
|
||||
const result = spawnSync(process.execPath, [script], { encoding: "utf8", env: {} });
|
||||
assert.equal(result.status, 1);
|
||||
const help = spawnSync(process.execPath, [script, "--help"], { encoding: "utf8", env: {} });
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /Usage:/);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
|
||||
const SOURCE_LOCALE = "zh-CN";
|
||||
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "ru", "zh-TW"];
|
||||
const TARGET_LOCALES = ["en", "es", "it", "ja", "ko", "pt-BR", "ru", "th", "zh-TW"];
|
||||
// `git show <ref>:<path>` needs forward slashes even on Windows, where
|
||||
// `path.join()` yields backslashes. Allow an override (CI keeps the default).
|
||||
const refPath = (path) => path.replaceAll("\\", "/");
|
||||
@@ -18,6 +18,7 @@ const TARGET_LABELS = {
|
||||
ko: "Korean",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
ru: "Russian",
|
||||
th: "Thai",
|
||||
"zh-TW": "Traditional Chinese used in Taiwan",
|
||||
};
|
||||
|
||||
|
||||
@@ -21,20 +21,32 @@ function fixture(context, platform = "darwin-arm64") {
|
||||
const binary = join(source, "package/bin/dbx-mcp");
|
||||
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
|
||||
copyFileSync(binary, join(source, "dbx-mcp"));
|
||||
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
|
||||
for (const [archive, member] of [
|
||||
["npm.tgz", "package/bin/dbx-mcp"],
|
||||
["github.tar.gz", "dbx-mcp"],
|
||||
]) {
|
||||
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
}
|
||||
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
|
||||
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
|
||||
const integrity =
|
||||
"sha512-" +
|
||||
createHash("sha512")
|
||||
.update(readFileSync(join(root, "npm.tgz")))
|
||||
.digest("base64");
|
||||
const checksum = createHash("sha256")
|
||||
.update(readFileSync(join(root, "github.tar.gz")))
|
||||
.digest("hex");
|
||||
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
|
||||
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
|
||||
writeFileSync(join(root, "refs.json"), JSON.stringify([
|
||||
{ ref: "refs/tags/packages-v0.4.9", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.96", object: {} },
|
||||
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.10", object: {} },
|
||||
]));
|
||||
writeFileSync(
|
||||
join(root, "refs.json"),
|
||||
JSON.stringify([
|
||||
{ ref: "refs/tags/packages-v0.4.9", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.96", object: {} },
|
||||
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.10", object: {} },
|
||||
]),
|
||||
);
|
||||
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
|
||||
const routes = {};
|
||||
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
|
||||
@@ -46,7 +58,9 @@ function fixture(context, platform = "darwin-arm64") {
|
||||
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
|
||||
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
|
||||
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
|
||||
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
|
||||
writeFileSync(
|
||||
join(mocks, "curl"),
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
@@ -57,9 +71,13 @@ if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.
|
||||
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
|
||||
if (!routes[url]) process.exit(22);
|
||||
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
|
||||
`, { mode: 0o755 });
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
|
||||
writeFileSync(join(mocks, "codesign"), `#!${process.execPath}
|
||||
writeFileSync(
|
||||
join(mocks, "codesign"),
|
||||
`#!${process.execPath}
|
||||
const args = process.argv.slice(2);
|
||||
const mode = process.env.SIGNATURE_MODE || 'valid';
|
||||
const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"';
|
||||
@@ -73,13 +91,17 @@ console.error('Identifier=com.dbx.app.mcp');
|
||||
console.error('TeamIdentifier=TVDM965TDL');
|
||||
console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026'));
|
||||
console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */')));
|
||||
`, { mode: 0o755 });
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
|
||||
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
|
||||
const run = (extra = {}) => spawnSync("sh", [installer], {
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
|
||||
encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
const run = (extra = {}) =>
|
||||
spawnSync("sh", [installer], {
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
return { root, home, mocks, binary: installedBinary, marker, run };
|
||||
}
|
||||
|
||||
@@ -92,7 +114,10 @@ for (const platform of platforms) {
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
|
||||
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
|
||||
assert.equal(json.mcpServers.dbx.command, setup.binary);
|
||||
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
|
||||
const tomlCommand = result.stdout
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("command = "))
|
||||
.slice(10);
|
||||
assert.equal(JSON.parse(tomlCommand), setup.binary);
|
||||
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
|
||||
});
|
||||
|
||||
@@ -63,13 +63,7 @@ export function detectIssueCommand(commentBody) {
|
||||
|
||||
export function findEligibleMergedPullRequest({ context, commentUser, repository }) {
|
||||
return context.pullRequests
|
||||
.filter(
|
||||
(pullRequest) =>
|
||||
pullRequest.state === "MERGED" &&
|
||||
pullRequest.baseRefName === context.defaultBranch &&
|
||||
pullRequest.repository?.nameWithOwner === repository &&
|
||||
sameLogin(pullRequest.author?.login, commentUser),
|
||||
)
|
||||
.filter((pullRequest) => pullRequest.state === "MERGED" && pullRequest.baseRefName === context.defaultBranch && pullRequest.repository?.nameWithOwner === repository && sameLogin(pullRequest.author?.login, commentUser))
|
||||
.sort((left, right) => (right.mergedAt || "").localeCompare(left.mergedAt || ""))[0];
|
||||
}
|
||||
|
||||
@@ -92,10 +86,7 @@ async function handleUnclaim({ issueNumber, commentUser, client }) {
|
||||
|
||||
const assignee = context.assignees.find(({ login }) => sameLogin(login, commentUser));
|
||||
if (!assignee) {
|
||||
await client.commentIssue(
|
||||
issueNumber,
|
||||
`❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`,
|
||||
);
|
||||
await client.commentIssue(issueNumber, `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`);
|
||||
return { status: "not-assignee" };
|
||||
}
|
||||
|
||||
@@ -116,28 +107,15 @@ async function handleClose({ issueNumber, commentUser, repository, client }) {
|
||||
|
||||
const pullRequest = findEligibleMergedPullRequest({ context, commentUser, repository });
|
||||
if (!pullRequest) {
|
||||
await client.commentIssue(
|
||||
issueNumber,
|
||||
`❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`,
|
||||
);
|
||||
await client.commentIssue(issueNumber, `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`);
|
||||
return { status: "no-merged-pull-request" };
|
||||
}
|
||||
|
||||
await client.closeIssue(
|
||||
issueNumber,
|
||||
`✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`,
|
||||
);
|
||||
await client.closeIssue(issueNumber, `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`);
|
||||
return { status: "closed", pullRequest };
|
||||
}
|
||||
|
||||
export async function handleIssueCommand({
|
||||
issueNumber,
|
||||
commentBody,
|
||||
commentUser,
|
||||
commentUserType,
|
||||
repository,
|
||||
client,
|
||||
}) {
|
||||
export async function handleIssueCommand({ issueNumber, commentBody, commentUser, commentUserType, repository, client }) {
|
||||
const command = detectIssueCommand(commentBody);
|
||||
if (!command) return { status: "ignored" };
|
||||
if (commentUserType === "Bot") return { status: "ignored-bot" };
|
||||
@@ -158,17 +136,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
|
||||
|
||||
return {
|
||||
async getIssueContext(issueNumber) {
|
||||
return JSON.parse(
|
||||
await runGh([
|
||||
"issue",
|
||||
"view",
|
||||
String(issueNumber),
|
||||
"--repo",
|
||||
repository,
|
||||
"--json",
|
||||
"state,assignees",
|
||||
]),
|
||||
);
|
||||
return JSON.parse(await runGh(["issue", "view", String(issueNumber), "--repo", repository, "--json", "state,assignees"]));
|
||||
},
|
||||
|
||||
async getIssueCloseContext(issueNumber) {
|
||||
@@ -176,18 +144,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
|
||||
let context;
|
||||
|
||||
do {
|
||||
const args = [
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
`owner=${owner}`,
|
||||
"-f",
|
||||
`name=${name}`,
|
||||
"-F",
|
||||
`number=${issueNumber}`,
|
||||
"-f",
|
||||
`query=${ISSUE_CLOSE_CONTEXT_QUERY}`,
|
||||
];
|
||||
const args = ["api", "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issueNumber}`, "-f", `query=${ISSUE_CLOSE_CONTEXT_QUERY}`];
|
||||
if (endCursor) args.push("-f", `endCursor=${endCursor}`);
|
||||
|
||||
const response = JSON.parse(await runGh(args));
|
||||
@@ -205,11 +162,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
|
||||
if (!context.defaultBranch) throw new Error(`Default branch was not found for ${repository}`);
|
||||
}
|
||||
|
||||
context.pullRequests.push(
|
||||
...issue.timelineItems.nodes
|
||||
.map((node) => node.source)
|
||||
.filter((source) => source?.__typename === "PullRequest"),
|
||||
);
|
||||
context.pullRequests.push(...issue.timelineItems.nodes.map((node) => node.source).filter((source) => source?.__typename === "PullRequest"));
|
||||
|
||||
const { hasNextPage, endCursor: nextCursor } = issue.timelineItems.pageInfo;
|
||||
endCursor = hasNextPage ? nextCursor : undefined;
|
||||
@@ -231,17 +184,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
|
||||
},
|
||||
|
||||
async closeIssue(issueNumber, comment) {
|
||||
await runGh([
|
||||
"issue",
|
||||
"close",
|
||||
String(issueNumber),
|
||||
"--repo",
|
||||
repository,
|
||||
"--reason",
|
||||
"completed",
|
||||
"--comment",
|
||||
comment,
|
||||
]);
|
||||
await runGh(["issue", "close", String(issueNumber), "--repo", repository, "--reason", "completed", "--comment", comment]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createGitHubClient,
|
||||
detectIssueCommand,
|
||||
findEligibleMergedPullRequest,
|
||||
handleIssueCommand,
|
||||
} from "./issue-commands.mjs";
|
||||
import { createGitHubClient, detectIssueCommand, findEligibleMergedPullRequest, handleIssueCommand } from "./issue-commands.mjs";
|
||||
|
||||
const repository = "t8y2/dbx";
|
||||
|
||||
@@ -117,13 +112,7 @@ test("findEligibleMergedPullRequest requires the assignee's merged PR on the def
|
||||
repository: { nameWithOwner: repository },
|
||||
};
|
||||
const context = closeContext({
|
||||
pullRequests: [
|
||||
{ ...eligible, number: 101, state: "OPEN" },
|
||||
{ ...eligible, number: 102, baseRefName: "release" },
|
||||
{ ...eligible, number: 103, author: { login: "someone-else" } },
|
||||
{ ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } },
|
||||
eligible,
|
||||
],
|
||||
pullRequests: [{ ...eligible, number: 101, state: "OPEN" }, { ...eligible, number: 102, baseRefName: "release" }, { ...eligible, number: 103, author: { login: "someone-else" } }, { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, eligible],
|
||||
});
|
||||
|
||||
assert.equal(findEligibleMergedPullRequest({ context, commentUser: "contributor", repository }), eligible);
|
||||
@@ -175,9 +164,7 @@ test("close succeeds after the assignee's linked PR is merged", async () => {
|
||||
|
||||
test("commands ignore bots and closed unclaim requests", async () => {
|
||||
const botClient = recordingClient();
|
||||
const botResult = await handleIssueCommand(
|
||||
commandOptions("/claim", botClient, { commentUserType: "Bot" }),
|
||||
);
|
||||
const botResult = await handleIssueCommand(commandOptions("/claim", botClient, { commentUserType: "Bot" }));
|
||||
assert.equal(botResult.status, "ignored-bot");
|
||||
assert.deepEqual(botClient.calls, []);
|
||||
|
||||
@@ -213,10 +200,7 @@ test("GitHub client aggregates paginated pull request cross references", async (
|
||||
assignees: { nodes: [{ login: "contributor" }] },
|
||||
timelineItems: {
|
||||
pageInfo: { hasNextPage: false, endCursor: "done" },
|
||||
nodes: [
|
||||
{ source: { __typename: "Issue", number: 11 } },
|
||||
{ source: { __typename: "PullRequest", number: 12 } },
|
||||
],
|
||||
nodes: [{ source: { __typename: "Issue", number: 11 } }, { source: { __typename: "PullRequest", number: 12 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -233,7 +217,10 @@ test("GitHub client aggregates paginated pull request cross references", async (
|
||||
});
|
||||
|
||||
const context = await client.getIssueCloseContext("123");
|
||||
assert.deepEqual(context.pullRequests.map(({ number }) => number), [10, 12]);
|
||||
assert.deepEqual(
|
||||
context.pullRequests.map(({ number }) => number),
|
||||
[10, 12],
|
||||
);
|
||||
assert.equal(args.length, 2);
|
||||
assert.equal(args[1].includes("endCursor=next-page"), true);
|
||||
});
|
||||
|
||||
@@ -17,14 +17,8 @@ test("reads the JDBC plugin version from Gradle", () => {
|
||||
});
|
||||
|
||||
test("checks Gradle and manifest versions together", () => {
|
||||
assert.deepEqual(
|
||||
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }),
|
||||
[],
|
||||
);
|
||||
assert.match(
|
||||
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0],
|
||||
/build\.gradle is 0\.1\.27/,
|
||||
);
|
||||
assert.deepEqual(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }), []);
|
||||
assert.match(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0], /build\.gradle is 0\.1\.27/);
|
||||
});
|
||||
|
||||
test("bumps Gradle and manifest versions for JDBC source changes", () => {
|
||||
|
||||
@@ -17,52 +17,14 @@ const USER_PRIORITY_LABELS = {
|
||||
P3: { color: "bfd4f2", description: "User-reported priority: low" },
|
||||
};
|
||||
|
||||
const LABEL_PALETTE = [
|
||||
"0e8a16",
|
||||
"1d76db",
|
||||
"5319e7",
|
||||
"c2e0c6",
|
||||
"bfd4f2",
|
||||
"d4c5f9",
|
||||
"fef2c0",
|
||||
"fbca04",
|
||||
"f9d0c4",
|
||||
"e99695",
|
||||
"f29513",
|
||||
"c5def5",
|
||||
];
|
||||
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
|
||||
|
||||
const globallyAmbiguousAliases = new Set([
|
||||
"access",
|
||||
"ch",
|
||||
"h2",
|
||||
"iris",
|
||||
"jdbc",
|
||||
"mq",
|
||||
"pg",
|
||||
"spark",
|
||||
]);
|
||||
const globallyAmbiguousAliases = new Set(["access", "ch", "h2", "iris", "jdbc", "mq", "pg", "spark"]);
|
||||
|
||||
const genericDatabaseValues = [
|
||||
"general",
|
||||
"generic",
|
||||
"global",
|
||||
"all",
|
||||
"any",
|
||||
"none",
|
||||
"n/a",
|
||||
"na",
|
||||
"通用",
|
||||
"全部",
|
||||
"所有",
|
||||
"无",
|
||||
"不适用",
|
||||
];
|
||||
const genericDatabaseValues = ["general", "generic", "global", "all", "any", "none", "n/a", "na", "通用", "全部", "所有", "无", "不适用"];
|
||||
|
||||
const drivers = databaseIssueDrivers;
|
||||
const exactAsciiAliases = new Set(
|
||||
drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)),
|
||||
);
|
||||
const exactAsciiAliases = new Set(drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)));
|
||||
|
||||
function loadIssue() {
|
||||
if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) {
|
||||
@@ -113,20 +75,12 @@ function extractInlineField(body, predicate) {
|
||||
}
|
||||
|
||||
function extractDatabaseField(body) {
|
||||
const matchesDatabaseHeading = (heading) => (
|
||||
heading.includes("数据库类型") ||
|
||||
heading.toLowerCase().includes("database type") ||
|
||||
/^database$/i.test(heading)
|
||||
);
|
||||
return extractIssueFormSection(body, matchesDatabaseHeading)
|
||||
|| extractInlineField(body, matchesDatabaseHeading);
|
||||
const matchesDatabaseHeading = (heading) => heading.includes("数据库类型") || heading.toLowerCase().includes("database type") || /^database$/i.test(heading);
|
||||
return extractIssueFormSection(body, matchesDatabaseHeading) || extractInlineField(body, matchesDatabaseHeading);
|
||||
}
|
||||
|
||||
function extractPriorityField(body) {
|
||||
return extractIssueFormSection(body, (heading) => (
|
||||
heading.includes("优先级") ||
|
||||
heading.toLowerCase().includes("priority")
|
||||
));
|
||||
return extractIssueFormSection(body, (heading) => heading.includes("优先级") || heading.toLowerCase().includes("priority"));
|
||||
}
|
||||
|
||||
function extractTitleSummaryField(body) {
|
||||
@@ -146,21 +100,24 @@ function extractTitleSummaryField(body) {
|
||||
}
|
||||
|
||||
function matchPriorityLabel(value) {
|
||||
const match = String(value || "").normalize("NFKC").match(/\bP([0-3])\b/i);
|
||||
const match = String(value || "")
|
||||
.normalize("NFKC")
|
||||
.match(/\bP([0-3])\b/i);
|
||||
return match ? `P${match[1]}` : null;
|
||||
}
|
||||
|
||||
function genericIssueTitlePrefix(title) {
|
||||
const normalized = String(title || "").normalize("NFKC").trim().replace(/\s+/g, " ");
|
||||
const normalized = String(title || "")
|
||||
.normalize("NFKC")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
const bracketMatch = normalized.match(/^([\[【][^\]】]*(?:bug|feature|feat|compatibility|question)[^\]】]*[\]】])$/i);
|
||||
if (bracketMatch) return bracketMatch[1];
|
||||
|
||||
const bareMatch = normalized.match(/^(bug|feature|feat|compatibility|question):?$/i);
|
||||
if (!bareMatch) return null;
|
||||
|
||||
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : (
|
||||
bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase()
|
||||
);
|
||||
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase();
|
||||
return `[${normalizedPrefix}]`;
|
||||
}
|
||||
|
||||
@@ -179,14 +136,7 @@ function cleanTitleSummaryLine(line) {
|
||||
}
|
||||
|
||||
function isSkippableTitleSummaryLine(line) {
|
||||
return (
|
||||
/^DBX debug log$/i.test(line) ||
|
||||
/^(Exported|User agent|Platform|Timezone):/i.test(line) ||
|
||||
/^(Native|Tauri) log dir:/i.test(line) ||
|
||||
/^=+.*logs?.*=+$/i.test(line) ||
|
||||
/^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) ||
|
||||
/^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line)
|
||||
);
|
||||
return /^DBX debug log$/i.test(line) || /^(Exported|User agent|Platform|Timezone):/i.test(line) || /^(Native|Tauri) log dir:/i.test(line) || /^=+.*logs?.*=+$/i.test(line) || /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line);
|
||||
}
|
||||
|
||||
function firstSentence(value) {
|
||||
@@ -199,9 +149,7 @@ function firstSentence(value) {
|
||||
|
||||
for (const line of lines) {
|
||||
const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u);
|
||||
const sentence = (sentenceMatch ? sentenceMatch[1] : line)
|
||||
.replace(/[。!?!?.]+$/u, "")
|
||||
.trim();
|
||||
const sentence = (sentenceMatch ? sentenceMatch[1] : line).replace(/[。!?!?.]+$/u, "").trim();
|
||||
if (sentence.length >= 4) {
|
||||
return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence;
|
||||
}
|
||||
@@ -251,11 +199,7 @@ function levenshteinDistance(left, right) {
|
||||
const current = [leftIndex + 1];
|
||||
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
|
||||
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
|
||||
current[rightIndex + 1] = Math.min(
|
||||
current[rightIndex] + 1,
|
||||
previous[rightIndex + 1] + 1,
|
||||
previous[rightIndex] + substitutionCost,
|
||||
);
|
||||
current[rightIndex + 1] = Math.min(current[rightIndex] + 1, previous[rightIndex + 1] + 1, previous[rightIndex] + substitutionCost);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
@@ -318,9 +262,7 @@ function aliasMatches(text, alias) {
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join("[\\s._/-]*");
|
||||
const trailingBoundary = /[0-9]$/.test(normalizedAlias)
|
||||
? "(?:[^a-z0-9]|$)"
|
||||
: "(?:[^a-z0-9]|$|(?=v?\\d))";
|
||||
const trailingBoundary = /[0-9]$/.test(normalizedAlias) ? "(?:[^a-z0-9]|$)" : "(?:[^a-z0-9]|$|(?=v?\\d))";
|
||||
const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i");
|
||||
return pattern.test(normalizedText);
|
||||
}
|
||||
@@ -332,10 +274,7 @@ function isGenericDatabaseValue(value) {
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)))
|
||||
);
|
||||
return parts.length > 0 && parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)));
|
||||
}
|
||||
|
||||
function matchDrivers(text, { allowAmbiguous }) {
|
||||
@@ -456,24 +395,18 @@ export function triageIssue(issue) {
|
||||
const hasDatabaseField = databaseField.length > 0;
|
||||
const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField);
|
||||
const sourceText = hasDatabaseField ? databaseField : issue.title || "";
|
||||
const matchedDrivers = isGenericDatabaseValue(sourceText)
|
||||
? []
|
||||
: uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
|
||||
const matchedDrivers = isGenericDatabaseValue(sourceText) ? [] : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
|
||||
const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false }));
|
||||
if (hasDatabaseField && titleMatchedDrivers.length > 0) {
|
||||
matchedDrivers.push(...titleMatchedDrivers);
|
||||
}
|
||||
if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) {
|
||||
matchedDrivers.push(
|
||||
...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })),
|
||||
);
|
||||
matchedDrivers.push(...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })));
|
||||
}
|
||||
|
||||
const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers);
|
||||
const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`);
|
||||
const targetLabelColors = Object.fromEntries(
|
||||
uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]),
|
||||
);
|
||||
const targetLabelColors = Object.fromEntries(uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]));
|
||||
const priorityField = extractPriorityField(issue.body || "");
|
||||
const targetPriorityValue = matchPriorityLabel(priorityField);
|
||||
const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null;
|
||||
@@ -484,22 +417,11 @@ export function triageIssue(issue) {
|
||||
const existingLabels = labelNames(issue.labels);
|
||||
const titleToUpdate = inferIssueTitle(issue.title || "", issue.body || "");
|
||||
const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX));
|
||||
const staleDatabaseLabels = hasDatabaseField
|
||||
? existingDatabaseLabels.filter((name) => !targetLabels.includes(name))
|
||||
: [];
|
||||
const staleDatabaseLabels = hasDatabaseField ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) : [];
|
||||
const existingPriorityLabels = existingLabels.filter((name) => name.startsWith(USER_PRIORITY_PREFIX));
|
||||
const stalePriorityLabels = targetPriorityLabel
|
||||
? existingPriorityLabels.filter((name) => name !== targetPriorityLabel)
|
||||
: [];
|
||||
const labelsToAdd = [...targetLabels, targetPriorityLabel]
|
||||
.filter(Boolean)
|
||||
.filter((name) => !existingLabels.includes(name));
|
||||
const labelSpecs = [
|
||||
...uniqueMatchedDrivers.map(databaseLabelSpec),
|
||||
...(targetPriorityValue
|
||||
? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }]
|
||||
: []),
|
||||
];
|
||||
const stalePriorityLabels = targetPriorityLabel ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) : [];
|
||||
const labelsToAdd = [...targetLabels, targetPriorityLabel].filter(Boolean).filter((name) => !existingLabels.includes(name));
|
||||
const labelSpecs = [...uniqueMatchedDrivers.map(databaseLabelSpec), ...(targetPriorityValue ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] : [])];
|
||||
const summary = {
|
||||
issue: issue.number,
|
||||
databaseField: hasDatabaseField ? databaseField : null,
|
||||
|
||||
@@ -21,8 +21,8 @@ function issueWithDatabase(database, title = "[Bug]") {
|
||||
}
|
||||
|
||||
function matchedLabels(database, title) {
|
||||
return triageIssue(issueWithDatabase(database, title)).summary.labelsToAdd
|
||||
.filter((label) => label.startsWith("db/"))
|
||||
return triageIssue(issueWithDatabase(database, title))
|
||||
.summary.labelsToAdd.filter((label) => label.startsWith("db/"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ test("database issue catalog covers every DatabaseType", () => {
|
||||
test("database label synchronization includes every catalog entry once", () => {
|
||||
const labelNames = allDatabaseLabelSpecs().map((label) => label.name);
|
||||
assert.equal(new Set(labelNames).size, databaseIssueDrivers.length);
|
||||
assert.deepEqual(
|
||||
labelNames.sort(),
|
||||
databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort(),
|
||||
);
|
||||
assert.deepEqual(labelNames.sort(), databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort());
|
||||
});
|
||||
|
||||
test("Kingbase aliases retain historical and current company names", () => {
|
||||
|
||||
@@ -33,24 +33,12 @@ export const STATIC_LABEL_SPECS = {
|
||||
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//;
|
||||
|
||||
const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]);
|
||||
const LABEL_PALETTE = [
|
||||
"0e8a16",
|
||||
"1d76db",
|
||||
"5319e7",
|
||||
"c2e0c6",
|
||||
"bfd4f2",
|
||||
"d4c5f9",
|
||||
"fef2c0",
|
||||
"fbca04",
|
||||
"f9d0c4",
|
||||
"e99695",
|
||||
"f29513",
|
||||
"c5def5",
|
||||
];
|
||||
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
|
||||
|
||||
const DRIVER_DATABASE_ALIASES = {
|
||||
"cassandra-go": "cassandra",
|
||||
gbase8a: "gbase",
|
||||
gbase8c: "gbase",
|
||||
gbase8s: "gbase",
|
||||
"h2-legacy": "h2",
|
||||
kafka: "mq",
|
||||
@@ -86,12 +74,7 @@ const DATABASE_PATH_TOKENS = [
|
||||
[/sqlserver/i, "sqlserver"],
|
||||
];
|
||||
|
||||
const PACKAGE_DEPENDENCY_SECTIONS = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
];
|
||||
const PACKAGE_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
||||
|
||||
function stablePaletteColor(value) {
|
||||
let hash = 0;
|
||||
@@ -106,14 +89,14 @@ function normalizeChangedFiles(changedFiles) {
|
||||
}
|
||||
|
||||
function conventionalType(title) {
|
||||
const normalized = String(title || "").trim().replace(/^[^\p{L}\p{N}]+/u, "");
|
||||
const normalized = String(title || "")
|
||||
.trim()
|
||||
.replace(/^[^\p{L}\p{N}]+/u, "");
|
||||
return /^([a-z]+)(?:\([^)]*\))?!?[::]/i.exec(normalized)?.[1]?.toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function isDocumentationFile(file) {
|
||||
return file.startsWith("docs/")
|
||||
|| /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file)
|
||||
|| /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
|
||||
return file.startsWith("docs/") || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
|
||||
}
|
||||
|
||||
export function inferTypeLabel(title, changedFiles) {
|
||||
@@ -130,10 +113,7 @@ export function inferAreaLabels(changedFiles) {
|
||||
const labels = new Set();
|
||||
const has = (predicate) => changedFiles.some(predicate);
|
||||
|
||||
if (has((file) => file === "package.json"
|
||||
|| file.startsWith("apps/desktop/")
|
||||
|| file.startsWith("packages/app-tests/")
|
||||
|| file.startsWith("src-tauri/"))) {
|
||||
if (has((file) => file === "package.json" || file.startsWith("apps/desktop/") || file.startsWith("packages/app-tests/") || file.startsWith("src-tauri/"))) {
|
||||
labels.add("area/desktop");
|
||||
}
|
||||
if (has((file) => CORE_CRATE_PATH.test(file) || file.startsWith("packages/node-core/"))) {
|
||||
@@ -143,27 +123,16 @@ export function inferAreaLabels(changedFiles) {
|
||||
labels.add("area/web");
|
||||
}
|
||||
if (has((file) => file.startsWith("agents/"))) labels.add("area/agents");
|
||||
if (has((file) => file.startsWith("crates/dbx-mcp/")
|
||||
|| file.startsWith("packages/mcp-")
|
||||
|| file.startsWith("packages/mcp-server/")
|
||||
|| file.startsWith("examples/mcp/"))) {
|
||||
if (has((file) => file.startsWith("crates/dbx-mcp/") || file.startsWith("packages/mcp-") || file.startsWith("packages/mcp-server/") || file.startsWith("examples/mcp/"))) {
|
||||
labels.add("area/mcp");
|
||||
}
|
||||
if (has((file) => file.startsWith("crates/dbx-cli/")
|
||||
|| file.startsWith("packages/cli-")
|
||||
|| file.startsWith("packages/cli/")
|
||||
|| file.startsWith("skills/dbx/")
|
||||
|| file.startsWith("examples/cli/"))) {
|
||||
if (has((file) => file.startsWith("crates/dbx-cli/") || file.startsWith("packages/cli-") || file.startsWith("packages/cli/") || file.startsWith("skills/dbx/") || file.startsWith("examples/cli/"))) {
|
||||
labels.add("area/cli");
|
||||
}
|
||||
if (has((file) => file.startsWith("plugins/"))) labels.add("area/plugins");
|
||||
if (has(isDocumentationFile)) labels.add("area/docs");
|
||||
if (has((file) => file.startsWith(".github/"))) labels.add("area/ci");
|
||||
if (has((file) => file.startsWith("deploy/")
|
||||
|| file === "flake.nix"
|
||||
|| file === "flake.lock"
|
||||
|| file === "Makefile"
|
||||
|| /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
|
||||
if (has((file) => file.startsWith("deploy/") || file === "flake.nix" || file === "flake.lock" || file === "Makefile" || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
|
||||
labels.add("area/deploy");
|
||||
}
|
||||
|
||||
@@ -178,29 +147,20 @@ export function isUiChangeFile(file) {
|
||||
if (isTestFile(file)) return false;
|
||||
|
||||
if (file.startsWith("apps/desktop/")) {
|
||||
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file)
|
||||
|| file.startsWith("apps/desktop/src/components/")
|
||||
|| file.startsWith("apps/desktop/src/i18n/")
|
||||
|| file.startsWith("apps/desktop/src/styles/")
|
||||
|| file.startsWith("apps/desktop/public/")
|
||||
|| isVisualAsset(file);
|
||||
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) || file.startsWith("apps/desktop/src/components/") || file.startsWith("apps/desktop/src/i18n/") || file.startsWith("apps/desktop/src/styles/") || file.startsWith("apps/desktop/public/") || isVisualAsset(file);
|
||||
}
|
||||
|
||||
if (file.startsWith("src-tauri/icons/")) return true;
|
||||
|
||||
if (file.startsWith("docs/")) {
|
||||
return /\.(?:css|jsx|tsx)$/i.test(file)
|
||||
|| file.startsWith("docs/components/")
|
||||
|| (file.startsWith("docs/public/") && isVisualAsset(file));
|
||||
return /\.(?:css|jsx|tsx)$/i.test(file) || file.startsWith("docs/components/") || (file.startsWith("docs/public/") && isVisualAsset(file));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTestFile(file) {
|
||||
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file)
|
||||
|| /\.(?:spec|test)\.[^/]+$/i.test(file)
|
||||
|| /(^|\/)packages\/app-tests\//.test(file);
|
||||
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) || /\.(?:spec|test)\.[^/]+$/i.test(file) || /(^|\/)packages\/app-tests\//.test(file);
|
||||
}
|
||||
|
||||
function normalizeDatabaseType(value, aliases) {
|
||||
@@ -248,8 +208,7 @@ export function parsePackageDependencyNames(source) {
|
||||
|
||||
function unquoteTomlKey(value) {
|
||||
const trimmed = String(value || "").trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
@@ -340,17 +299,10 @@ function dependencySide(file) {
|
||||
if (file === "package.json" || file === "docs/package.json" || file === "packages/mongo-shell/package.json") {
|
||||
return "frontend";
|
||||
}
|
||||
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file)
|
||||
|| file === "packages/cli/package.json"
|
||||
|| file === "packages/mcp-server/package.json") {
|
||||
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) || file === "packages/cli/package.json" || file === "packages/mcp-server/package.json") {
|
||||
return "backend";
|
||||
}
|
||||
if (file === "Cargo.toml"
|
||||
|| file.endsWith("/Cargo.toml")
|
||||
|| file.endsWith("/build.gradle")
|
||||
|| file.endsWith("/build.gradle.kts")
|
||||
|| file.endsWith("/go.mod")
|
||||
|| file.endsWith("/pom.xml")) {
|
||||
if (file === "Cargo.toml" || file.endsWith("/Cargo.toml") || file.endsWith("/build.gradle") || file.endsWith("/build.gradle.kts") || file.endsWith("/go.mod") || file.endsWith("/pom.xml")) {
|
||||
return "backend";
|
||||
}
|
||||
return null;
|
||||
@@ -382,13 +334,7 @@ export function inferAddedDependencies(changedFiles, readBaseFile, readHeadFile)
|
||||
return added;
|
||||
}
|
||||
|
||||
export function evaluatePullRequestLabels({
|
||||
title,
|
||||
changedFiles,
|
||||
knownDatabaseTypes,
|
||||
readBaseFile = () => null,
|
||||
readHeadFile = () => null,
|
||||
}) {
|
||||
export function evaluatePullRequestLabels({ title, changedFiles, knownDatabaseTypes, readBaseFile = () => null, readHeadFile = () => null }) {
|
||||
const files = normalizeChangedFiles(changedFiles);
|
||||
const areaLabels = inferAreaLabels(files);
|
||||
const labels = new Set(areaLabels.length > 3 ? ["area/multiple"] : areaLabels);
|
||||
@@ -510,12 +456,7 @@ async function removeLabel(pullRequestNumber, name) {
|
||||
}
|
||||
|
||||
function isManagedLabel(name) {
|
||||
return TYPE_LABELS.has(name)
|
||||
|| name.startsWith("area/")
|
||||
|| name.startsWith(DATABASE_LABEL_PREFIX)
|
||||
|| name.startsWith("dependencies/")
|
||||
|| name === "tests-only"
|
||||
|| name === "ui-change";
|
||||
return TYPE_LABELS.has(name) || name.startsWith("area/") || name.startsWith(DATABASE_LABEL_PREFIX) || name.startsWith("dependencies/") || name === "tests-only" || name === "ui-change";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -547,14 +488,20 @@ async function main() {
|
||||
};
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
pullRequest: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
...result,
|
||||
existingLabels,
|
||||
labelsToAdd,
|
||||
labelsToRemove,
|
||||
}, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
pullRequest: pullRequest.number,
|
||||
title: pullRequest.title,
|
||||
...result,
|
||||
existingLabels,
|
||||
labelsToAdd,
|
||||
labelsToRemove,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("Dry run enabled; no GitHub API calls were made");
|
||||
|
||||
@@ -1,42 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
evaluatePullRequestLabels,
|
||||
inferAddedDependencies,
|
||||
inferDatabaseTypes,
|
||||
parseCargoDependencyNames,
|
||||
parseGoDependencyNames,
|
||||
parseGradleDependencyNames,
|
||||
parseMavenDependencyNames,
|
||||
parsePackageDependencyNames,
|
||||
} from "./label-pull-request.mjs";
|
||||
import { evaluatePullRequestLabels, inferAddedDependencies, inferDatabaseTypes, parseCargoDependencyNames, parseGoDependencyNames, parseGradleDependencyNames, parseMavenDependencyNames, parsePackageDependencyNames } from "./label-pull-request.mjs";
|
||||
|
||||
const knownDatabaseTypes = new Set([
|
||||
"access",
|
||||
"cassandra",
|
||||
"doris",
|
||||
"jdbc",
|
||||
"mongodb",
|
||||
"mq",
|
||||
"mysql",
|
||||
"neo4j",
|
||||
"oceanbase-oracle",
|
||||
"oracle",
|
||||
"postgres",
|
||||
"redis",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"vastbase",
|
||||
]);
|
||||
const knownDatabaseTypes = new Set(["access", "cassandra", "doris", "jdbc", "mongodb", "mq", "mysql", "neo4j", "oceanbase-oracle", "oracle", "postgres", "redis", "sqlite", "sqlserver", "vastbase"]);
|
||||
|
||||
test("labels a desktop MySQL UI fix", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "fix(mysql): improve connection dialog",
|
||||
changedFiles: [
|
||||
"apps/desktop/src/components/connection/MySqlConnectionDialog.vue",
|
||||
"apps/desktop/src/i18n/en.ts",
|
||||
],
|
||||
changedFiles: ["apps/desktop/src/components/connection/MySqlConnectionDialog.vue", "apps/desktop/src/i18n/en.ts"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
@@ -65,15 +37,7 @@ test("labels the bundled DBX skill as CLI work", () => {
|
||||
|
||||
test("maps agent and dialect paths to existing database types", () => {
|
||||
assert.deepEqual(
|
||||
inferDatabaseTypes([
|
||||
"agents/drivers/oracle-go/go.mod",
|
||||
"agents/drivers/cassandra-go/go.mod",
|
||||
"agents/drivers/neo4j-go/go.mod",
|
||||
"agents/drivers/vastbase-go/go.mod",
|
||||
"agents/drivers/kafka/build.gradle",
|
||||
"plugins/dialects/postgresql.yaml",
|
||||
"plugins/dialects/oceanbase.yaml",
|
||||
], knownDatabaseTypes),
|
||||
inferDatabaseTypes(["agents/drivers/oracle-go/go.mod", "agents/drivers/cassandra-go/go.mod", "agents/drivers/neo4j-go/go.mod", "agents/drivers/vastbase-go/go.mod", "agents/drivers/kafka/build.gradle", "plugins/dialects/postgresql.yaml", "plugins/dialects/oceanbase.yaml"], knownDatabaseTypes),
|
||||
["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"],
|
||||
);
|
||||
});
|
||||
@@ -81,10 +45,7 @@ test("maps agent and dialect paths to existing database types", () => {
|
||||
test("labels tests-only changes", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "test(core): cover transaction recovery",
|
||||
changedFiles: [
|
||||
"crates/dbx-core/tests/transaction_recovery.rs",
|
||||
"apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts",
|
||||
],
|
||||
changedFiles: ["crates/dbx-core/tests/transaction_recovery.rs", "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
@@ -94,10 +55,7 @@ test("labels tests-only changes", () => {
|
||||
test("does not treat component tests or MCP server code as UI or deploy changes", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "🐛 fix(mcp): cover server routing",
|
||||
changedFiles: [
|
||||
"apps/desktop/src/components/mcp/McpPanel.spec.ts",
|
||||
"packages/mcp-server/src/index.ts",
|
||||
],
|
||||
changedFiles: ["apps/desktop/src/components/mcp/McpPanel.spec.ts", "packages/mcp-server/src/index.ts"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
@@ -117,15 +75,7 @@ test("recognizes conventional titles with a full-width colon", () => {
|
||||
test("recognizes extracted core crates and their database sources", () => {
|
||||
const result = evaluatePullRequestLabels({
|
||||
title: "fix(sql): preserve PostgreSQL query contracts",
|
||||
changedFiles: [
|
||||
"crates/dbx-driver-postgres/src/postgres.rs",
|
||||
"crates/dbx-sql-core/src/sql.rs",
|
||||
"crates/dbx-types/src/types.rs",
|
||||
"crates/dbx-platform/src/process.rs",
|
||||
"crates/dbx-ai-provider/src/ai.rs",
|
||||
"crates/dbx-formats/src/csv_export.rs",
|
||||
"crates/dbx-plugin-runtime/src/plugins.rs",
|
||||
],
|
||||
changedFiles: ["crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-sql-core/src/sql.rs", "crates/dbx-types/src/types.rs", "crates/dbx-platform/src/process.rs", "crates/dbx-ai-provider/src/ai.rs", "crates/dbx-formats/src/csv_export.rs", "crates/dbx-plugin-runtime/src/plugins.rs"],
|
||||
knownDatabaseTypes,
|
||||
});
|
||||
|
||||
@@ -168,11 +118,11 @@ test("detects frontend and backend additions in the same pull request", () => {
|
||||
const manifests = {
|
||||
base: {
|
||||
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0" } }),
|
||||
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\n",
|
||||
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\n',
|
||||
},
|
||||
head: {
|
||||
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0", zod: "4" } }),
|
||||
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n",
|
||||
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\nserde = "1"\n',
|
||||
},
|
||||
};
|
||||
const result = evaluatePullRequestLabels({
|
||||
@@ -183,13 +133,7 @@ test("detects frontend and backend additions in the same pull request", () => {
|
||||
readHeadFile: (file) => manifests.head[file],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.labels, [
|
||||
"area/core",
|
||||
"area/desktop",
|
||||
"dependencies/backend",
|
||||
"dependencies/frontend",
|
||||
"enhancement",
|
||||
]);
|
||||
assert.deepEqual(result.labels, ["area/core", "area/desktop", "dependencies/backend", "dependencies/frontend", "enhancement"]);
|
||||
});
|
||||
|
||||
test("ignores lockfile-only dependency changes", () => {
|
||||
@@ -204,7 +148,8 @@ test("ignores lockfile-only dependency changes", () => {
|
||||
|
||||
test("parses dependency names from supported backend manifests", () => {
|
||||
assert.deepEqual(
|
||||
[...parseCargoDependencyNames(`
|
||||
[
|
||||
...parseCargoDependencyNames(`
|
||||
[workspace.dependencies]
|
||||
serde = "1"
|
||||
|
||||
@@ -213,39 +158,50 @@ windows-sys = "0.61"
|
||||
|
||||
[dependencies.mysql_async]
|
||||
git = "https://example.com/mysql_async"
|
||||
`)].sort(),
|
||||
`),
|
||||
].sort(),
|
||||
["mysql_async", "serde", "windows-sys"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...parseGradleDependencyNames(`
|
||||
[
|
||||
...parseGradleDependencyNames(`
|
||||
implementation 'com.zaxxer:HikariCP:7.0.2'
|
||||
testImplementation project(':test-support')
|
||||
runtimeOnly libs.slf4j.nop
|
||||
`)].sort(),
|
||||
`),
|
||||
].sort(),
|
||||
["catalog:libs.slf4j.nop", "com.zaxxer:HikariCP", "project::test-support"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...parseGoDependencyNames(`
|
||||
[
|
||||
...parseGoDependencyNames(`
|
||||
require github.com/example/direct v1.0.0
|
||||
require (
|
||||
github.com/example/block v2.0.0
|
||||
)
|
||||
`)].sort(),
|
||||
`),
|
||||
].sort(),
|
||||
["github.com/example/block", "github.com/example/direct"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...parseMavenDependencyNames(`
|
||||
[
|
||||
...parseMavenDependencyNames(`
|
||||
<dependencies>
|
||||
<dependency><groupId>org.example</groupId><artifactId>client</artifactId><version>1</version></dependency>
|
||||
</dependencies>
|
||||
`)],
|
||||
`),
|
||||
],
|
||||
["org.example:client"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...parsePackageDependencyNames(JSON.stringify({
|
||||
dependencies: { vue: "3" },
|
||||
devDependencies: { vitest: "4" },
|
||||
}))].sort(),
|
||||
[
|
||||
...parsePackageDependencyNames(
|
||||
JSON.stringify({
|
||||
dependencies: { vue: "3" },
|
||||
devDependencies: { vitest: "4" },
|
||||
}),
|
||||
),
|
||||
].sort(),
|
||||
["vitest", "vue"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -23,14 +14,7 @@ const NATIVE_MODULES = new Set(["duckdb", "oracle", "oracle-oci", "xugu", "kingb
|
||||
const NATIVE_MODULE_PLATFORMS = {
|
||||
"oracle-oci": ["windows-x64"],
|
||||
};
|
||||
const PLATFORMS = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
"linux-aarch64",
|
||||
"linux-x64",
|
||||
"windows-aarch64",
|
||||
"windows-x64",
|
||||
];
|
||||
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
|
||||
|
||||
function expectedNativePlatforms(moduleName) {
|
||||
return [...(NATIVE_MODULE_PLATFORMS[moduleName] ?? PLATFORMS)].sort();
|
||||
@@ -93,9 +77,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
|
||||
const missingPlatforms = expectedPlatforms.filter((platform) => !nativePlatforms.includes(platform));
|
||||
const extraPlatforms = nativePlatforms.filter((platform) => !expectedPlatforms.includes(platform));
|
||||
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
|
||||
throw new Error(
|
||||
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
|
||||
);
|
||||
throw new Error(`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +101,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
|
||||
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
|
||||
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
|
||||
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
|
||||
throw new Error(
|
||||
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
|
||||
);
|
||||
throw new Error(`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
|
||||
}
|
||||
for (const platform of platforms) {
|
||||
jreAssets.push({
|
||||
@@ -210,13 +190,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
|
||||
|
||||
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
|
||||
if (filenames.has(javaName)) {
|
||||
extractRawDriver(join(packagesDir, javaName), {
|
||||
name: javaName,
|
||||
moduleName,
|
||||
kind: "jar",
|
||||
platform: "",
|
||||
releaseVersion,
|
||||
}, outputDir);
|
||||
extractRawDriver(
|
||||
join(packagesDir, javaName),
|
||||
{
|
||||
name: javaName,
|
||||
moduleName,
|
||||
kind: "jar",
|
||||
platform: "",
|
||||
releaseVersion,
|
||||
},
|
||||
outputDir,
|
||||
);
|
||||
extracted += 1;
|
||||
}
|
||||
|
||||
@@ -225,13 +209,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
|
||||
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
|
||||
if (!filenames.has(nativeName)) continue;
|
||||
nativePlatforms.push(platform);
|
||||
extractRawDriver(join(packagesDir, nativeName), {
|
||||
name: nativeName,
|
||||
moduleName,
|
||||
kind: "native",
|
||||
platform,
|
||||
releaseVersion,
|
||||
}, outputDir);
|
||||
extractRawDriver(
|
||||
join(packagesDir, nativeName),
|
||||
{
|
||||
name: nativeName,
|
||||
moduleName,
|
||||
kind: "native",
|
||||
platform,
|
||||
releaseVersion,
|
||||
},
|
||||
outputDir,
|
||||
);
|
||||
extracted += 1;
|
||||
}
|
||||
|
||||
@@ -253,23 +241,7 @@ function gh(args, options = {}) {
|
||||
|
||||
function downloadReleaseAssets(assets, downloadDir) {
|
||||
if (assets.length === 0) return;
|
||||
const args = [
|
||||
"--fail",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"5",
|
||||
"--retry-all-errors",
|
||||
"--retry-delay",
|
||||
"2",
|
||||
"--connect-timeout",
|
||||
"30",
|
||||
"--parallel",
|
||||
"--parallel-immediate",
|
||||
"--parallel-max",
|
||||
"6",
|
||||
];
|
||||
const args = ["--fail", "--location", "--silent", "--show-error", "--retry", "5", "--retry-all-errors", "--retry-delay", "2", "--connect-timeout", "30", "--parallel", "--parallel-immediate", "--parallel-max", "6"];
|
||||
for (const asset of assets) {
|
||||
if (!asset.browser_download_url) {
|
||||
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
|
||||
@@ -359,7 +331,10 @@ function main() {
|
||||
}));
|
||||
|
||||
if (plannedAssets.length > 0) {
|
||||
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
|
||||
downloadReleaseAssets(
|
||||
plannedAssets.map((asset) => asset.releaseAsset),
|
||||
downloadDir,
|
||||
);
|
||||
}
|
||||
|
||||
for (const asset of plannedAssets) {
|
||||
|
||||
@@ -3,23 +3,12 @@ import test from "node:test";
|
||||
|
||||
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
|
||||
|
||||
const platforms = [
|
||||
"macos-aarch64",
|
||||
"macos-x64",
|
||||
"linux-aarch64",
|
||||
"linux-x64",
|
||||
"windows-aarch64",
|
||||
"windows-x64",
|
||||
];
|
||||
const platforms = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
|
||||
|
||||
test("collects complete reusable Java, native, and JRE assets", () => {
|
||||
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
|
||||
const kingbase = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const jre = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
|
||||
);
|
||||
const kingbase = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]));
|
||||
const jre = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]));
|
||||
const registry = {
|
||||
drivers: {
|
||||
access: { version: "0.1.34", jar: access },
|
||||
@@ -39,95 +28,93 @@ test("collects complete reusable Java, native, and JRE assets", () => {
|
||||
|
||||
assert.equal(plan.driverAssets.length, 7);
|
||||
assert.equal(plan.jreAssets.length, 6);
|
||||
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
|
||||
assert.deepEqual(
|
||||
plan.driverAssets.map((asset) => asset.moduleName),
|
||||
["access", ...Array(6).fill("kingbase")],
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an incomplete reusable native platform set", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { vastbase: "0.1.38" },
|
||||
modules: ["vastbase"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { vastbase: "0.1.38" },
|
||||
modules: ["vastbase"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=macos-aarch64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all TDengine native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { tdengine: "0.1.40" },
|
||||
modules: ["tdengine"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { tdengine: "0.1.40" },
|
||||
modules: ["tdengine"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all Neo4j native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { neo4j: "0.1.40" },
|
||||
modules: ["neo4j"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { neo4j: "0.1.40" },
|
||||
modules: ["neo4j"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all IoTDB native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = { drivers: { iotdb: { version: "0.1.30", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { iotdb: "0.1.30" },
|
||||
modules: ["iotdb"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { iotdb: "0.1.30" },
|
||||
modules: ["iotdb"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires all RocketMQ native platforms when reusing a release", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { rocketmq: "0.1.40" },
|
||||
modules: ["rocketmq"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { rocketmq: "0.1.40" },
|
||||
modules: ["rocketmq"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64/,
|
||||
);
|
||||
});
|
||||
@@ -146,7 +133,10 @@ test("reuses the Windows-only Oracle OCI native package", () => {
|
||||
|
||||
// Windows-only is the complete platform set for this module, so requiring all
|
||||
// six platforms would reject a correct release.
|
||||
assert.deepEqual(plan.driverAssets.map((asset) => asset.platform), ["windows-x64"]);
|
||||
assert.deepEqual(
|
||||
plan.driverAssets.map((asset) => asset.platform),
|
||||
["windows-x64"],
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an Oracle OCI release that lost its Windows native package", () => {
|
||||
@@ -154,21 +144,20 @@ test("rejects an Oracle OCI release that lost its Windows native package", () =>
|
||||
const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { "oracle-oci": "0.1.0" },
|
||||
modules: ["oracle-oci"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor(Object.values(native)),
|
||||
versions: { "oracle-oci": "0.1.0" },
|
||||
modules: ["oracle-oci"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/missing=windows-x64, extra=linux-x64/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
|
||||
const native = Object.fromEntries(
|
||||
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
|
||||
);
|
||||
const native = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]));
|
||||
const registry = {
|
||||
drivers: {
|
||||
duckdb: {
|
||||
@@ -193,7 +182,10 @@ test("ignores zero-size legacy JAR placeholders for native-only modules", () =>
|
||||
});
|
||||
|
||||
assert.equal(plan.driverAssets.length, 6);
|
||||
assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false);
|
||||
assert.equal(
|
||||
plan.driverAssets.some((asset) => asset.kind === "jar"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a registry version that differs from the effective baseline", () => {
|
||||
@@ -201,13 +193,14 @@ test("rejects a registry version that differs from the effective baseline", () =
|
||||
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
|
||||
|
||||
assert.throws(
|
||||
() => collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor([access]),
|
||||
versions: { access: "0.1.34" },
|
||||
modules: ["access"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
() =>
|
||||
collectReusableAssetPlan({
|
||||
registry,
|
||||
release: releaseFor([access]),
|
||||
versions: { access: "0.1.34" },
|
||||
modules: ["access"],
|
||||
reuseJre: false,
|
||||
}),
|
||||
/registry=0\.1\.33, expected=0\.1\.34/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,12 +22,18 @@ async function command(executable, args, signal) {
|
||||
return new Promise((resolveCommand, reject) => {
|
||||
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], signal });
|
||||
let output = "";
|
||||
child.stdout.on("data", (data) => { output += data; });
|
||||
child.stderr.on("data", (data) => { output += data; });
|
||||
child.stdout.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
child.stderr.on("data", (data) => {
|
||||
output += data;
|
||||
});
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 120000);
|
||||
const fail = () => reject(new Error(`${executable.split("/").at(-1)} ${args[0]} failed.`));
|
||||
let failed = false;
|
||||
child.on("error", () => { failed = true; });
|
||||
child.on("error", () => {
|
||||
failed = true;
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0 && !failed) resolveCommand(output);
|
||||
@@ -43,10 +49,7 @@ export async function verifyMcp(binary, options = {}) {
|
||||
if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file.");
|
||||
await command(ctx.codesign, ["--verify", "--strict", "--all-architectures", "-R", `=${ctx.requirement}`, binary], ctx.signal);
|
||||
const info = await command(ctx.codesign, ["--display", "--verbose=4", "--requirements", "-", binary], ctx.signal);
|
||||
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`)
|
||||
|| !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`)
|
||||
|| !/^Timestamp=(?!none$).+/m.test(info)
|
||||
|| /Signature=adhoc/.test(info)) {
|
||||
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) || !/^Timestamp=(?!none$).+/m.test(info) || /Signature=adhoc/.test(info)) {
|
||||
throw new Error("MCP signature must have the expected identifier, team and secure timestamp.");
|
||||
}
|
||||
const designated = info.match(/^designated => (.+)$/m)?.[1];
|
||||
@@ -66,7 +69,10 @@ export async function signMcp(binary, options = {}) {
|
||||
throw new Error("APPLE_CERTIFICATE must be a base64 PKCS12 bundle.");
|
||||
}
|
||||
const originalList = await command(ctx.security, ["list-keychains", "-d", "user"], ctx.signal);
|
||||
const originalKeychains = originalList.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line.trim()));
|
||||
const originalKeychains = originalList
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line.trim()));
|
||||
if (!originalKeychains.every((keychain) => typeof keychain === "string")) throw new Error("Invalid keychain search list.");
|
||||
const directory = mkdtempSync(join(ctx.env.RUNNER_TEMP || ctx.env.TMPDIR || tmpdir(), "dbx-mcp-signing-"));
|
||||
chmodSync(directory, 0o700);
|
||||
@@ -84,22 +90,33 @@ export async function signMcp(binary, options = {}) {
|
||||
await command(ctx.security, ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain], ctx.signal);
|
||||
await command(ctx.security, ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains], ctx.signal);
|
||||
const identities = await command(ctx.security, ["find-identity", "-v", "-p", "codesigning", keychain], ctx.signal);
|
||||
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)]
|
||||
.filter(([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`)
|
||||
&& (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()));
|
||||
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)].filter(
|
||||
([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()),
|
||||
);
|
||||
if (candidates.length !== 1) throw new Error("Expected exactly one valid configured Developer ID Application identity in the temporary keychain.");
|
||||
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier,
|
||||
"--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
|
||||
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
|
||||
await verifyMcp(binary, options);
|
||||
} catch (failure) {
|
||||
error = failure;
|
||||
} finally {
|
||||
const failures = [];
|
||||
try { await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } catch (failure) { failures.push(failure); }
|
||||
try {
|
||||
await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]);
|
||||
} catch (failure) {
|
||||
failures.push(failure);
|
||||
}
|
||||
if (existsSync(keychain)) {
|
||||
try { await command(ctx.security, ["delete-keychain", keychain]); } catch (failure) { failures.push(failure); }
|
||||
try {
|
||||
await command(ctx.security, ["delete-keychain", keychain]);
|
||||
} catch (failure) {
|
||||
failures.push(failure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
} catch (failure) {
|
||||
failures.push(failure);
|
||||
}
|
||||
try { rmSync(directory, { recursive: true, force: true }); } catch (failure) { failures.push(failure); }
|
||||
if (failures.length) error = new Error("MCP signing keychain cleanup failed; refusing publication.", { cause: error });
|
||||
}
|
||||
if (error) throw error;
|
||||
|
||||
@@ -10,81 +10,12 @@ const MAX_QUERY_LENGTH = 480;
|
||||
const MAX_CANDIDATES = 3;
|
||||
const SEARCH_RESULT_LIMIT = 20;
|
||||
|
||||
const ignoredSectionPatterns = [
|
||||
/^来源$/i,
|
||||
/^source$/i,
|
||||
/数据库类型/i,
|
||||
/database type/i,
|
||||
/支持信息/i,
|
||||
/support info/i,
|
||||
/优先级/i,
|
||||
/priority/i,
|
||||
/补充信息/i,
|
||||
/additional context/i,
|
||||
/环境信息/i,
|
||||
/environment/i,
|
||||
];
|
||||
const ignoredSectionPatterns = [/^来源$/i, /^source$/i, /数据库类型/i, /database type/i, /支持信息/i, /support info/i, /优先级/i, /priority/i, /补充信息/i, /additional context/i, /环境信息/i, /environment/i];
|
||||
|
||||
const genericLatinTokens = new Set([
|
||||
"ai",
|
||||
"alter",
|
||||
"bug",
|
||||
"datagrip",
|
||||
"dbeaver",
|
||||
"dbx",
|
||||
"delete",
|
||||
"desktop",
|
||||
"feature",
|
||||
"from",
|
||||
"insert",
|
||||
"issue",
|
||||
"navicat",
|
||||
"question",
|
||||
"request",
|
||||
"select",
|
||||
"sql",
|
||||
"support",
|
||||
"table",
|
||||
"update",
|
||||
"version",
|
||||
"web",
|
||||
"where",
|
||||
"windows",
|
||||
]);
|
||||
const genericLatinTokens = new Set(["ai", "alter", "bug", "datagrip", "dbeaver", "dbx", "delete", "desktop", "feature", "from", "insert", "issue", "navicat", "question", "request", "select", "sql", "support", "table", "update", "version", "web", "where", "windows"]);
|
||||
|
||||
const genericCjkTokens = new Set([
|
||||
"一个",
|
||||
"以及",
|
||||
"使用",
|
||||
"功能",
|
||||
"可以",
|
||||
"当前",
|
||||
"支持",
|
||||
"数据库",
|
||||
"新增",
|
||||
"增加",
|
||||
"希望",
|
||||
"异常",
|
||||
"操作",
|
||||
"所在",
|
||||
"数据",
|
||||
"显示",
|
||||
"没有",
|
||||
"现在",
|
||||
"设置",
|
||||
"问题",
|
||||
"进行",
|
||||
"错误",
|
||||
"需要",
|
||||
]);
|
||||
const identifierQueryNoiseTokens = new Set([
|
||||
"失效",
|
||||
"弹出",
|
||||
"快捷",
|
||||
"提醒",
|
||||
"时候",
|
||||
"选择",
|
||||
]);
|
||||
const genericCjkTokens = new Set(["一个", "以及", "使用", "功能", "可以", "当前", "支持", "数据库", "新增", "增加", "希望", "异常", "操作", "所在", "数据", "显示", "没有", "现在", "设置", "问题", "进行", "错误", "需要"]);
|
||||
const identifierQueryNoiseTokens = new Set(["失效", "弹出", "快捷", "提醒", "时候", "选择"]);
|
||||
|
||||
const cjkSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
|
||||
const databaseDrivers = databaseIssueDrivers;
|
||||
@@ -107,7 +38,9 @@ function labelNames(labels) {
|
||||
}
|
||||
|
||||
function stripIssuePrefix(title) {
|
||||
return String(title || "").replace(/^\s*\[[^\]]+\]\s*/u, "").trim();
|
||||
return String(title || "")
|
||||
.replace(/^\s*\[[^\]]+\]\s*/u, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function stripMarkdown(value) {
|
||||
@@ -143,9 +76,7 @@ function databaseField(body) {
|
||||
}
|
||||
|
||||
function relevantBody(body) {
|
||||
const sections = parseIssueSections(body).filter(
|
||||
({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)),
|
||||
);
|
||||
const sections = parseIssueSections(body).filter(({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)));
|
||||
return stripMarkdown(sections.map(({ content }) => content).join("\n")).slice(0, 2400);
|
||||
}
|
||||
|
||||
@@ -197,9 +128,7 @@ function semanticTokens(value) {
|
||||
|
||||
export function searchTitleTerms(issue) {
|
||||
const identifiers = technicalIdentifiers(issue);
|
||||
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter(
|
||||
(token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token),
|
||||
);
|
||||
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter((token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token));
|
||||
return tokens.slice(0, 8).join(" ").slice(0, MAX_QUERY_LENGTH);
|
||||
}
|
||||
|
||||
@@ -207,11 +136,7 @@ function latinTokens(value) {
|
||||
return new Set(
|
||||
normalizeText(value)
|
||||
.match(/[a-z0-9][a-z0-9+._-]{1,}/gu)
|
||||
?.filter((token) => (
|
||||
!genericLatinTokens.has(token)
|
||||
&& !/^v?\d+(?:[._-]\d+)*$/u.test(token)
|
||||
&& !/^(.)\1{2,}$/u.test(token)
|
||||
)) || [],
|
||||
?.filter((token) => !genericLatinTokens.has(token) && !/^v?\d+(?:[._-]\d+)*$/u.test(token) && !/^(.)\1{2,}$/u.test(token)) || [],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -334,26 +259,15 @@ export function buildCorpusContext(issue, candidates) {
|
||||
const frequencies = new Map();
|
||||
|
||||
for (const token of queryTitleTokens) {
|
||||
const documentFrequency = candidateTitleTokens.reduce(
|
||||
(count, tokens) => count + (tokens.has(token) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const documentFrequency = candidateTitleTokens.reduce((count, tokens) => count + (tokens.has(token) ? 1 : 0), 0);
|
||||
frequencies.set(token, documentFrequency);
|
||||
const queryFrequencyBoost = 1 + Math.log(Math.max(1, tokenOccurrenceCount(issueTitle, token)));
|
||||
weights.set(token, inverseDocumentFrequency(candidates.length, documentFrequency) * queryFrequencyBoost);
|
||||
}
|
||||
|
||||
const observedWeights = [...queryTitleTokens]
|
||||
.filter((token) => (frequencies.get(token) || 0) > 0)
|
||||
.map((token) => weights.get(token) || 0);
|
||||
const observedWeights = [...queryTitleTokens].filter((token) => (frequencies.get(token) || 0) > 0).map((token) => weights.get(token) || 0);
|
||||
const maximumWeight = Math.max(0, ...observedWeights);
|
||||
const anchorTokens = new Set(
|
||||
[...queryTitleTokens].filter((token) => (
|
||||
maximumWeight > 0
|
||||
&& (frequencies.get(token) || 0) > 0
|
||||
&& (weights.get(token) || 0) >= maximumWeight * 0.9
|
||||
)),
|
||||
);
|
||||
const anchorTokens = new Set([...queryTitleTokens].filter((token) => maximumWeight > 0 && (frequencies.get(token) || 0) > 0 && (weights.get(token) || 0) >= maximumWeight * 0.9));
|
||||
|
||||
return { queryTitleTokens, candidateTitleTokens, weights, anchorTokens };
|
||||
}
|
||||
@@ -369,29 +283,18 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
|
||||
const candidateTitle = stripIssuePrefix(candidate.title);
|
||||
const issueBody = relevantBody(issue.body).slice(0, 1200);
|
||||
const candidateBody = relevantBody(candidate.body).slice(0, 1200);
|
||||
const titleSimilarity = diceCoefficient(
|
||||
characterNgrams(contentTitleText(issue)),
|
||||
characterNgrams(contentTitleText(candidate)),
|
||||
);
|
||||
const titleSimilarity = diceCoefficient(characterNgrams(contentTitleText(issue)), characterNgrams(contentTitleText(candidate)));
|
||||
const bodySimilarity = diceCoefficient(characterNgrams(issueBody), characterNgrams(candidateBody));
|
||||
const context = corpusContext || buildCorpusContext(issue, [candidate]);
|
||||
const candidateTitleTokens = corpusContext
|
||||
? context.candidateTitleTokens[rank]
|
||||
: context.candidateTitleTokens[0];
|
||||
const candidateTitleTokens = corpusContext ? context.candidateTitleTokens[rank] : context.candidateTitleTokens[0];
|
||||
const titleCoverage = weightedCoverage(context.queryTitleTokens, candidateTitleTokens, context.weights);
|
||||
const anchorHit = setsOverlap(candidateTitleTokens, context.anchorTokens);
|
||||
const issueIdentifiers = technicalIdentifiers(issue);
|
||||
const candidateIdentifiers = technicalIdentifiers(candidate);
|
||||
const identifierCoverage = issueIdentifiers.size === 0
|
||||
? 0
|
||||
: intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size;
|
||||
const identifierCoverage = issueIdentifiers.size === 0 ? 0 : intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size;
|
||||
const rankPrior = 1 / Math.log2(rank + 2);
|
||||
|
||||
let score = titleCoverage * 0.45
|
||||
+ titleSimilarity * 0.25
|
||||
+ bodySimilarity * 0.1
|
||||
+ identifierCoverage * 0.15
|
||||
+ rankPrior * 0.05;
|
||||
let score = titleCoverage * 0.45 + titleSimilarity * 0.25 + bodySimilarity * 0.1 + identifierCoverage * 0.15 + rankPrior * 0.05;
|
||||
const issueTypes = typeLabels(issue.labels);
|
||||
const candidateTypes = typeLabels(candidate.labels);
|
||||
if (issueTypes.size > 0 && candidateTypes.size > 0) {
|
||||
@@ -401,10 +304,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
|
||||
|
||||
// Like Discourse, retrieval is deliberately broad and public suggestions
|
||||
// require a separate threshold. Rare title terms act as BM25F-style anchors.
|
||||
const accepted = score >= 0.34
|
||||
|| (anchorHit && titleCoverage >= 0.38 && score >= 0.2)
|
||||
|| (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28)
|
||||
|| (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28);
|
||||
const accepted = score >= 0.34 || (anchorHit && titleCoverage >= 0.38 && score >= 0.2) || (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28) || (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28);
|
||||
|
||||
return {
|
||||
accepted,
|
||||
@@ -421,9 +321,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
|
||||
}
|
||||
|
||||
export function rankCandidates(issue, items) {
|
||||
const candidates = items.filter(
|
||||
(candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number),
|
||||
);
|
||||
const candidates = items.filter((candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number));
|
||||
const context = buildCorpusContext(issue, candidates);
|
||||
return candidates
|
||||
.map((candidate, rank) => ({ candidate, ...scoreCandidate(issue, candidate, rank, context) }))
|
||||
@@ -510,11 +408,13 @@ export async function run({ issue = loadIssue(), client } = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const github = client || new GitHubClient({
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
apiBase: process.env.GITHUB_API_URL,
|
||||
});
|
||||
const github =
|
||||
client ||
|
||||
new GitHubClient({
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
repository: process.env.GITHUB_REPOSITORY,
|
||||
apiBase: process.env.GITHUB_API_URL,
|
||||
});
|
||||
if (await github.hasExistingComment(issue.number)) {
|
||||
console.log("Similar issue comment already exists");
|
||||
return [];
|
||||
|
||||
@@ -2,15 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
GitHubClient,
|
||||
formatComment,
|
||||
rankCandidates,
|
||||
run,
|
||||
scoreCandidate,
|
||||
searchTerms,
|
||||
searchTitleTerms,
|
||||
} from "./suggest-similar-issues.mjs";
|
||||
import { GitHubClient, formatComment, rankCandidates, run, scoreCandidate, searchTerms, searchTitleTerms } from "./suggest-similar-issues.mjs";
|
||||
|
||||
const baseIssue = {
|
||||
number: 4043,
|
||||
@@ -213,7 +205,10 @@ test("uses corpus rarity to reject generic real-world matches", () => {
|
||||
];
|
||||
|
||||
const ranked = rankCandidates(issue, candidates);
|
||||
assert.deepEqual(ranked.map(({ candidate }) => candidate.number), [2201]);
|
||||
assert.deepEqual(
|
||||
ranked.map(({ candidate }) => candidate.number),
|
||||
[2201],
|
||||
);
|
||||
assert.equal(ranked[0].signals.anchorHit, true);
|
||||
});
|
||||
|
||||
@@ -247,9 +242,7 @@ test("ranks candidates, excludes the current issue and limits output", () => {
|
||||
});
|
||||
|
||||
test("formats a cautious localized comment with concise issue references", () => {
|
||||
const comment = formatComment(baseIssue, [
|
||||
{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } },
|
||||
]);
|
||||
const comment = formatComment(baseIssue, [{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } }]);
|
||||
|
||||
assert.match(comment, /^<!-- dbx-similar-issues -->/u);
|
||||
assert.match(comment, /\n- #82\n/u);
|
||||
@@ -272,16 +265,20 @@ test("run searches and posts one idempotent comment", async (t) => {
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url.startsWith("/search/issues?")) {
|
||||
response.end(JSON.stringify({
|
||||
search_type: "hybrid",
|
||||
items: [{
|
||||
number: 82,
|
||||
title: "Ctrl+Enter 执行当前 SQL 语句",
|
||||
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
|
||||
state: "open",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
}],
|
||||
}));
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
search_type: "hybrid",
|
||||
items: [
|
||||
{
|
||||
number: 82,
|
||||
title: "Ctrl+Enter 执行当前 SQL 语句",
|
||||
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
|
||||
state: "open",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/comments")) {
|
||||
|
||||
@@ -59,13 +59,15 @@ export function stripDownloadSection(body) {
|
||||
// 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。
|
||||
// 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。
|
||||
function stripIssueRefs(text) {
|
||||
return text
|
||||
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
|
||||
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
|
||||
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
|
||||
.replace(/\(closes #\d+,\s*/g, "(")
|
||||
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
|
||||
.replace(/,\s*closes #\d+/g, "");
|
||||
return (
|
||||
text
|
||||
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
|
||||
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
|
||||
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
|
||||
.replace(/\(closes #\d+,\s*/g, "(")
|
||||
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
|
||||
.replace(/,\s*closes #\d+/g, "")
|
||||
);
|
||||
}
|
||||
|
||||
// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2,
|
||||
@@ -187,9 +189,7 @@ function buildLatestEnNotes(enReleasesJson) {
|
||||
}
|
||||
|
||||
export function buildLatestReleaseNotes(releases) {
|
||||
const latest = releases
|
||||
.filter(isAppRelease)
|
||||
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
|
||||
const latest = releases.filter(isAppRelease).sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
|
||||
if (!latest) return null;
|
||||
return { version: latest.tag_name, notes: latest.body || "" };
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@ async function main() {
|
||||
});
|
||||
|
||||
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
|
||||
await mapWithConcurrency(assets, args.concurrency, (assetPath) =>
|
||||
uploadWithRetry(client, release.id, assetPath, args.overwriteExisting),
|
||||
);
|
||||
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath, args.overwriteExisting));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -70,9 +68,7 @@ function parseArgs(argv) {
|
||||
}
|
||||
if (!args.token) throw new Error("CNB_TOKEN is required.");
|
||||
if (!args.githubReleasePath || (!args.metadataOnly && !args.assetsDir)) {
|
||||
throw new Error(
|
||||
"Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]",
|
||||
);
|
||||
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]");
|
||||
}
|
||||
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
|
||||
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
|
||||
@@ -162,19 +158,13 @@ export class CnbClient {
|
||||
}
|
||||
|
||||
async deleteAsset(releaseId, assetId) {
|
||||
await this.request(
|
||||
"DELETE",
|
||||
`/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`,
|
||||
);
|
||||
await this.request("DELETE", `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`);
|
||||
}
|
||||
|
||||
async listReleases(pageSize = 100) {
|
||||
const releases = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await this.request(
|
||||
"GET",
|
||||
`/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`,
|
||||
);
|
||||
const batch = await this.request("GET", `/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`);
|
||||
if (!Array.isArray(batch)) throw new Error("CNB release list response must be an array.");
|
||||
releases.push(...batch);
|
||||
if (batch.length < pageSize) return releases;
|
||||
|
||||
@@ -103,11 +103,7 @@ test("ensureRelease explicitly marks a promoted release as latest", async (t) =>
|
||||
token: "test-token",
|
||||
});
|
||||
|
||||
await client.ensureRelease(
|
||||
"v1.2.3",
|
||||
{ name: "DBX v1.2.3", body: "Stable", isPrerelease: false },
|
||||
{ makeLatest: true },
|
||||
);
|
||||
await client.ensureRelease("v1.2.3", { name: "DBX v1.2.3", body: "Stable", isPrerelease: false }, { makeLatest: true });
|
||||
|
||||
assert.deepEqual(requests, [
|
||||
{ method: "GET", url: "/dbxio.com/dbx/-/releases/tags/v1.2.3", body: null },
|
||||
@@ -157,26 +153,19 @@ test("metadata-only sync can mark the release as latest without reading an asset
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "sync-cnb-release-"));
|
||||
t.after(() => rm(tempDir, { recursive: true, force: true }));
|
||||
const releasePath = join(tempDir, "release.json");
|
||||
await writeFile(
|
||||
releasePath,
|
||||
JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }),
|
||||
);
|
||||
await writeFile(releasePath, JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }));
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const result = await new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
|
||||
CNB_REPOSITORY: "dbxio.com/dbx",
|
||||
CNB_TOKEN: "test-token",
|
||||
},
|
||||
const child = spawn(process.execPath, [scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"], {
|
||||
env: {
|
||||
...process.env,
|
||||
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
|
||||
CNB_REPOSITORY: "dbxio.com/dbx",
|
||||
CNB_TOKEN: "test-token",
|
||||
},
|
||||
);
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => (stdout += chunk));
|
||||
|
||||
@@ -24,8 +24,12 @@ carries the `ui-preview` label), the workflow does this:
|
||||
- has it plan scenes: what to click, double-click, hover, type and capture;
|
||||
- walks the plan in Chromium with `cursor.js` drawing the pointer, its trail and each
|
||||
click. It always takes screenshots. When the plan interacts, it also records an mp4,
|
||||
never a GIF. A failed step is re-planned from the page as it is (up to three times);
|
||||
what still fails is listed honestly in the PR block.
|
||||
never a GIF — each scene on a page of its own, its load trimmed off and the takes
|
||||
joined, so scene changes read as cuts rather than the app reloading. A failed step
|
||||
is re-planned from the page as it is (up to three times),
|
||||
after an Escape clears anything the failure left open (a menu, a dialog); what still
|
||||
fails is listed honestly in the PR block, and shots taken after a failure in their
|
||||
scene are marked ⚠️ both there and on the Pages player.
|
||||
4. **publish.mjs publish** puts the files under `pr-<n>/<sha>/` on the `ui-previews` branch,
|
||||
which GitHub Pages serves, and rewrites the block in the description: the video's poster
|
||||
links to the player page (GitHub won't embed a video it didn't host), the screenshots are
|
||||
@@ -34,8 +38,11 @@ carries the `ui-preview` label), the workflow does this:
|
||||
|
||||
Security: the recording job runs the PR's code without asking anyone — its frontend build
|
||||
scripts, and in the rust lane the PR's backend itself. Any PR's code can therefore read
|
||||
`DEEPSEEK_API_KEY` (environment `ui-preview`), so the key there should have a low spending
|
||||
limit. That job has no write access, and the job that writes never runs the PR's code.
|
||||
the planner's API key (`PLAN_API_KEY`, environment `ui-preview`), so the key there should
|
||||
have a low spending limit. That job has no write access, and the job that writes never
|
||||
runs the PR's code. The planner talks to any OpenAI-compatible endpoint
|
||||
(`PLAN_API_URL`, default AtlasCloud; `PLAN_MODEL`, overridable via the
|
||||
`UI_PREVIEW_PLAN_MODEL` repo variable).
|
||||
|
||||
Known limits: the recording is of the web build, so desktop-shell behaviour (detached tabs,
|
||||
OS dialogs) is not covered; in the web lane the backend is the released dbx-web, so a PR
|
||||
@@ -47,7 +54,7 @@ the starting):
|
||||
|
||||
```sh
|
||||
cd .github/ui-preview && npm install && npx playwright install chromium
|
||||
DBX_URL=http://127.0.0.1:4280 DEEPSEEK_API_KEY=… DIFF_FILE=pr.diff PR_TITLE=… \
|
||||
DBX_URL=http://127.0.0.1:4280 PLAN_API_KEY=… DIFF_FILE=pr.diff PR_TITLE=… \
|
||||
OUT_DIR=/tmp/out SRC_DIR=../.. node record.mjs # a plan from DeepSeek
|
||||
PLAN_FILE=plan.json DBX_URL=… … node record.mjs # or a hand-written plan
|
||||
```
|
||||
|
||||
Generated
+10
-27
@@ -6,51 +6,34 @@
|
||||
"": {
|
||||
"name": "dbx-ui-preview",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"seed": "node seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
"playwright": "1.64.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ async function files() {
|
||||
// the description with the block in place of the old one, or at its foot
|
||||
function withBlock(body, block) {
|
||||
body = body || "";
|
||||
const a = body.indexOf(BEGIN), b = body.indexOf(END);
|
||||
const a = body.indexOf(BEGIN),
|
||||
b = body.indexOf(END);
|
||||
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
|
||||
return body.replace(/\s*$/, "") + "\n\n" + block;
|
||||
}
|
||||
@@ -102,7 +103,9 @@ async function pushMedia(sha, dir) {
|
||||
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
|
||||
base = git(["rev-parse", "FETCH_HEAD"], work);
|
||||
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
|
||||
} catch { /* the first one */ }
|
||||
} catch {
|
||||
/* the first one */
|
||||
}
|
||||
// this PR's latest run only, and no PR that's been closed
|
||||
for (const d of await fs.readdir(work).catch(() => [])) {
|
||||
const m = /^pr-(\d+)$/.exec(d);
|
||||
@@ -134,23 +137,31 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "&l
|
||||
// the page the video plays on: GitHub shows no video it didn't host itself
|
||||
function player(sha, m) {
|
||||
const shots = m.scenes.flatMap((s) => s.shots.map((x) => ({ ...x, scene: s.title })));
|
||||
const warned = m.errors?.length || shots.some((x) => x.warn);
|
||||
return `<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>#${pr} 界面预览 · ${short(sha)}</title>
|
||||
<style>:root{color-scheme:light dark;--bg:#f6f6f7;--fg:#1d1d1f;--muted:#6e6e73;--card:#fff;--line:#e5e5ea}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#111113;--fg:#f2f2f4;--muted:#9a9aa0;--card:#1c1c1f;--line:#2c2c30}}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif}
|
||||
main{max-width:1200px;margin:0 auto;padding:28px 16px 60px}h1{font-size:20px;margin:0 0 4px}.m{color:var(--muted);font-size:13px;margin:0 0 20px}
|
||||
.warn{margin:0 0 20px;padding:10px 14px;border:1px solid #c0392b;border-radius:10px;background:rgba(192,57,43,.07);color:#c0392b;font-size:14px}
|
||||
figure.shaky img{border-color:#c0392b}
|
||||
video,img{display:block;width:100%;border-radius:10px;border:1px solid var(--line);background:var(--card)}figure{margin:0 0 28px}figcaption{color:var(--muted);font-size:13px;margin-top:8px}
|
||||
a{color:inherit}</style>
|
||||
<main><h1>#${pr} 界面预览</h1><p class="m">commit ${short(sha)} · <a href="https://github.com/${repo}/pull/${pr}">回到 PR</a>${m.summary ? " · " + esc(m.summary) : ""}</p>
|
||||
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
|
||||
${shots.map((x) => `<figure><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
|
||||
${warned ? `<p class="warn">⚠️ 这次录制有 ${m.errors?.length || 0} 步没能按计划执行${shots.some((x) => x.warn) ? ";带 ⚠️ 的截图拍摄于步骤失败之后,画面未必是说明文字所描述的状态" : ""}。</p>` : ""}
|
||||
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。多个场景各自从应用初始状态开始。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
|
||||
${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${x.warn ? "⚠️ " : ""}${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
|
||||
</main></html>`;
|
||||
}
|
||||
|
||||
async function pagesURL() {
|
||||
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
|
||||
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
|
||||
try {
|
||||
return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/");
|
||||
} catch {
|
||||
const [o, n] = repo.split("/");
|
||||
return `https://${o.toLowerCase()}.github.io/${n}/`;
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
@@ -159,12 +170,12 @@ async function publish() {
|
||||
const dir = process.env.OUT_DIR;
|
||||
const result = process.env.RECORD_RESULT || "success";
|
||||
let m = null;
|
||||
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
|
||||
try {
|
||||
m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"));
|
||||
} catch {}
|
||||
if (result === "cancelled" && !m) return; // a newer commit took over
|
||||
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
|
||||
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
|
||||
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
|
||||
: `这次没录成(${result})`;
|
||||
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图" : m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制" : `这次没录成(${result})`;
|
||||
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
|
||||
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
|
||||
return;
|
||||
@@ -176,6 +187,7 @@ async function publish() {
|
||||
if (m.summary) md += `**改动**(按代码):${m.summary}\n\n`;
|
||||
if (m.mismatch) md += `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n`;
|
||||
if (m.unseen) md += `> [!NOTE]\n> **沙盒里看不到**:${m.unseen}\n\n`;
|
||||
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n\n`;
|
||||
if (m.video) {
|
||||
md += `[](${site})\n\n`;
|
||||
md += `<sub>▶️ 点图打开录屏(可暂停、拖动)· [直接下载 mp4](${raw}${m.video}) · 红线是鼠标轨迹,红色圆环是点击</sub>\n\n`;
|
||||
@@ -184,12 +196,13 @@ async function publish() {
|
||||
if (!s.shots.length) continue;
|
||||
md += `#### ${s.title}\n\n`;
|
||||
// shown at its own size (they're taken at 2x), a full page no wider than 760
|
||||
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.caption}\n\n`;
|
||||
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.warn ? "⚠️ " : ""}${x.caption}\n\n`;
|
||||
}
|
||||
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n`;
|
||||
await setBlock(md);
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish"))).catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
+267
-84
@@ -34,7 +34,10 @@ const MODEL = env("PLAN_MODEL", "deepseek-flash");
|
||||
const VIEW = { width: 1440, height: 900 };
|
||||
// never pressed: they delete the sandbox's own data or its setup
|
||||
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
|
||||
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
|
||||
const secrets = env("SECRETS", "")
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= 8);
|
||||
|
||||
const log = (...a) => console.log("[ui-preview]", ...a);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -46,7 +49,13 @@ const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [
|
||||
function outline() {
|
||||
const lines = [];
|
||||
const MAX = 420;
|
||||
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
|
||||
const own = (e) =>
|
||||
[...e.childNodes]
|
||||
.filter((n) => n.nodeType === 3)
|
||||
.map((n) => n.textContent.trim())
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 70);
|
||||
const shown = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
@@ -58,9 +67,19 @@ function outline() {
|
||||
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
|
||||
const tag = e.tagName.toLowerCase();
|
||||
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
|
||||
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
|
||||
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
|
||||
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
|
||||
const cls = [...e.classList]
|
||||
.slice(0, 4)
|
||||
.map((c) => "." + c)
|
||||
.join("");
|
||||
const data = [...e.attributes]
|
||||
.filter((a) => a.name.startsWith("data-") && a.value.length < 40)
|
||||
.slice(0, 3)
|
||||
.map((a) => `[${a.name}="${a.value}"]`)
|
||||
.join("");
|
||||
const aria = ["role", "aria-label", "title", "placeholder", "type"]
|
||||
.map((k) => (e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : ""))
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const text = own(e);
|
||||
const r = e.getBoundingClientRect();
|
||||
const away = r.top >= fold ? " (below the fold)" : "";
|
||||
@@ -75,8 +94,7 @@ function outline() {
|
||||
}
|
||||
|
||||
async function screenText(page) {
|
||||
return page.evaluate(() => document.documentElement.innerText + "\n" +
|
||||
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
|
||||
return page.evaluate(() => document.documentElement.innerText + "\n" + [...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
|
||||
}
|
||||
async function checkLeak(page, where) {
|
||||
if (!secrets.length) return;
|
||||
@@ -91,21 +109,30 @@ async function checkLeak(page, where) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deepseek(messages) {
|
||||
// any OpenAI-compatible endpoint; PLAN_API_URL/PLAN_API_KEY/PLAN_MODEL move
|
||||
// it off DeepSeek (AtlasCloud and friends) without touching this file
|
||||
const API_URL = env("PLAN_API_URL", "https://api.deepseek.com/v1/chat/completions");
|
||||
const API_KEY = env("PLAN_API_KEY") || env("DEEPSEEK_API_KEY");
|
||||
|
||||
async function askPlanner(messages) {
|
||||
// a reply cut short or not JSON is asked for again
|
||||
let last;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env("DEEPSEEK_API_KEY")}` },
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({ model: MODEL, messages, response_format: { type: "json_object" }, max_tokens: 16000 }),
|
||||
signal: AbortSignal.timeout(240e3),
|
||||
});
|
||||
if (!res.ok) throw new Error(`DeepSeek ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
const j = await res.json();
|
||||
const text = j.choices?.[0]?.message?.content ?? "";
|
||||
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
|
||||
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
|
||||
try {
|
||||
return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, ""));
|
||||
} catch (e) {
|
||||
last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`);
|
||||
log(last.message, "— asking again");
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
@@ -136,16 +163,22 @@ async function readTree(root) {
|
||||
|
||||
async function codeContext(diff, src) {
|
||||
if (!src) return "";
|
||||
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
|
||||
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
|
||||
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))].filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
|
||||
|
||||
// 1. the changed files
|
||||
let out = "";
|
||||
for (const f of changed.slice(0, 12)) {
|
||||
let body;
|
||||
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
|
||||
try {
|
||||
body = await fs.readFile(path.join(src, f), "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
|
||||
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
|
||||
if (out.length + chunk.length > 20e3) {
|
||||
out += "… (cut)\n";
|
||||
break;
|
||||
}
|
||||
out += chunk;
|
||||
}
|
||||
|
||||
@@ -165,16 +198,14 @@ async function codeContext(diff, src) {
|
||||
let entries = "";
|
||||
for (const name of components.slice(0, 6)) {
|
||||
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
const re = new RegExp(
|
||||
"import\\s+" + name + "\\b" +
|
||||
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
|
||||
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
|
||||
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
|
||||
const re = new RegExp("import\\s+" + name + "\\b" + "|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" + "|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" + "|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
|
||||
const flags = new Map(); // caller file -> Set of line numbers
|
||||
for (const p of files) {
|
||||
const t = await read(p);
|
||||
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
|
||||
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
|
||||
t.split("\n").forEach((l, i) => {
|
||||
if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i);
|
||||
});
|
||||
}
|
||||
for (const [p, lines] of flags) {
|
||||
// a dynamic import renames the component (const X = defineAsyncComponent):
|
||||
@@ -189,14 +220,19 @@ async function codeContext(diff, src) {
|
||||
}
|
||||
if (aliases.size > 1) {
|
||||
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
|
||||
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
|
||||
linesArr.forEach((l, i) => {
|
||||
if (tagRe.test(l)) lines.add(i);
|
||||
});
|
||||
}
|
||||
// the usage with its v-if/v-show/:open condition, and identifiers in it
|
||||
const span = [];
|
||||
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
|
||||
const cond = span.join("\n");
|
||||
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
|
||||
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
|
||||
if (entries.length + chunk.length > 9e3) {
|
||||
entries += "… (cut)\n";
|
||||
break;
|
||||
}
|
||||
entries += chunk;
|
||||
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
|
||||
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
|
||||
@@ -223,15 +259,17 @@ async function codeContext(diff, src) {
|
||||
return out + (entries ? `\n=== how the changes are reached (other components rendering/opening them — the entry points to plan from) ===\n${entries}` : "");
|
||||
}
|
||||
|
||||
const GUIDE = `You plan a short screen recording of dbx, a database GUI (shown here in a browser at ${VIEW.width}x${VIEW.height}). It runs for real on a sandbox machine: the web build of the app against a dbx-web backend with password protection off, and made-up data beside it (seed.mjs) so the pages that need data have some. That data: two saved SQLite connections, "商店 Shop (SQLite)" (tables customers · products · orders · audit_log, a view v_daily_revenue, foreign keys and a trigger) and "分析 Analytics (SQLite)" (events with 5000 rows — the grid pages — and daily_stats with 90 days); a saved-SQL folder "常用查询" with three files; and query history over the last three days across both connections, real executions mixed with seeded entries, a few failures among them. The UI language is ${LOCALE}. What it doesn't have: any server database (MySQL, PostgreSQL, Redis, MongoDB… none are connected — connection dialogs can be opened but not completed), no AI provider configured, no plugins or JDBC drivers installed, no SSH tunnels.
|
||||
const GUIDE = `You plan a short screen recording of dbx, a database GUI (shown here in a browser at ${VIEW.width}x${VIEW.height}). It runs for real on a sandbox machine: the web build of the app against a dbx-web backend with password protection off, and made-up data beside it (seed.mjs) so the pages that need data have some. That data: two saved SQLite connections, "商店 Shop (SQLite)" (tables customers, products, orders, audit_log, a view v_daily_revenue, foreign keys and a trigger) and "分析 Analytics (SQLite)" (events with 5000 rows — the grid pages — and daily_stats with 90 days); a small sample.csv (name,category,price,stock) for import dialogs; a saved-SQL folder "常用查询" with three files; and query history over the last three days across both connections, real executions mixed with seeded entries, a few failures among them. The UI language is ${LOCALE}. What it doesn't have: any server database (MySQL, PostgreSQL, Redis, MongoDB… none are connected — connection dialogs can be opened but not completed), no AI provider configured, no plugins or JDBC drivers installed, no SSH tunnels.
|
||||
|
||||
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar lists the connections; opening one (double-click, or right-click → its menu) opens its tables and its tabs — the query editor, table data, structure, ER diagram; the toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Find your way from the outline you are given.
|
||||
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar is a tree that opens one level at a time: a click on a connection lists its databases (SQLite shows one, "main 默认库"), a click on that database node lists its tables, and a click on a table opens its data grid — the query editor, table data, structure, ER diagram are that connection's tabs. Tree rows are plain divs inside div.connection-tree-content — click them as div.connection-tree-content div with the node's text, never as buttons. A double-click on a connection instead opens a database-browser tab, so working down the tree is how a table is reached; the outline you are given shows the app at rest, connections collapsed, so a node the plan needs that isn't in it has to be expanded into view first, not looked for on other pages. The search box above the tree (placeholder 搜索...) can reveal a node without that clicking, but while it holds text it filters the tree — a blank tree with text in the box means no match, and its text survives everything else the plan does: clear it with the × at its right end before walking the tree by hand, and never plan other steps while it holds text. The toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Action buttons in toolbars and dialogs are elements carrying data-slot="button", not the button tag — a step targeting "button" matches both, and grid toolbar actions also carry data-toolbar-action (camelCase, e.g. addRow). An editable table's grid toolbar has a 新增行 action (data-toolbar-action="addRow") that adds one row on a click; the icon button beside it, carrying aria-label="新增行" and no label text, opens a menu whose 插入多行… entry opens the insert-rows dialog (title 新增多行, row-count input #insert-rows-count). Two ways of working that reach further than clicks:
|
||||
- The query editor runs any SQL against the sandbox's SQLite databases (they are throwaway — writes are fine): when a state needs data (a result with no columns, an empty table, a table of a shape), open a connection, type the SQL that produces it and run it, and show the state it makes.
|
||||
- A file picker can't be clicked through, but a step can set it: { "do": "upload", "target": "input[type=file]", "value": "sample.csv" } — seed.mjs puts sample.csv (a small product list: name,category,price,stock) beside the databases, for import and mapping dialogs.
|
||||
|
||||
The diff is what the PR does. The title and description are the author's words and may be out of date or wrong: plan and summarise from the diff alone, and when the description claims something in the UI the diff doesn't do (a setting, a page, a button), don't look for it — say so in "mismatch".
|
||||
|
||||
You get the PR's title, description, diff, an outline of the app as it renders now, the changed frontend files themselves, and the entry chains: the other components that render the changed ones, under what v-if/v-show condition they are shown, and the code that flips those conditions. Use only selectors you can build from what the outline shows, or, for what only appears after an interaction (a dialog, a context menu, a dropdown, a tab), from the changed files and entry chains: the outline shows the app at rest, so an element the diff styles or builds being missing from it means you must open it first, not that it isn't there. The entry chains are the map — when a selector from the diff is nowhere in the outline, plan the steps that flip its condition (the toolbar button, the context menu, the tab the entry chain names), and don't search random other pages for it.
|
||||
|
||||
Write a plan that shows a reviewer exactly what this PR changes in the UI and whether it works as intended: go where the change is, do what a user would do to see it (open the connection, switch the tab, type the SQL, open the dialog…), and take a screenshot at each state that matters, before and after an interaction when that is the point. Captions say what is being done or what to look at ("打开订单表后的数据网格"), never what the result is or should be — the reviewer judges that from the picture, and the sandbox may differ from what you expect; and they never name a thing the diff doesn't add. When the change only shows with data this sandbox doesn't have (a server database, a plugin), say so in "unseen" and still show the place it would be. Keep it short: usually 1–3 scenes, under 15 steps each. Don't show unrelated pages. Never press anything that deletes, removes, clears or resets (删除/清空/移除/重置), and never run SQL that drops or truncates. Say ui_change false (and no scenes) only when the diff plainly changes nothing a user can see — only tests, comments, docs, or code that never reaches the screen; any change to the page's CSS, markup, text or behaviour is a UI change.
|
||||
Write a plan that shows a reviewer exactly what this PR changes in the UI and whether it works as intended: go where the change is, do what a user would do to see it (open the connection, switch the tab, type the SQL, open the dialog…), and take a screenshot at each state that matters, before and after an interaction when that is the point. Put a wish for text in the step's "text" field, never inside the target selector ([aria-label="设置"] hides it from the runner's text fallback — target "button" with text "设置" instead). Captions say what is being done or what to look at ("打开订单表后的数据网格"), never what the result is or should be — the reviewer judges that from the picture, and the sandbox may differ from what you expect; and they never name a thing the diff doesn't add. When the change only shows with data this sandbox doesn't have (a server database, a plugin), say so in "unseen" and still show the place it would be. Keep it short: usually 1–3 scenes, under 15 steps each. Don't show unrelated pages. Never press anything that deletes, removes, clears or resets (删除/清空/移除/重置), and never run SQL that drops or truncates. Say ui_change false (and no scenes) only when the diff plainly changes nothing a user can see — only tests, comments, docs, or code that never reaches the screen; any change to the page's CSS, markup, text or behaviour is a UI change.
|
||||
|
||||
Reply with JSON only:
|
||||
{
|
||||
@@ -245,6 +283,7 @@ Reply with JSON only:
|
||||
{ "do": "click", "target": "css selector", "text": "optional: only elements containing this text", "dblclick": false, "caption": "Chinese caption shown in the video" },
|
||||
{ "do": "hover", "target": "…", "caption": "…" },
|
||||
{ "do": "type", "target": "…", "value": "text to type", "caption": "…" },
|
||||
{ "do": "upload", "target": "input[type=file]", "value": "sample.csv", "caption": "…" },
|
||||
{ "do": "press", "key": "Escape" },
|
||||
{ "do": "scroll", "to": "css selector of what to bring into view", "text": "optional" },
|
||||
{ "do": "scroll", "target": "optional scroll container", "dy": 400 },
|
||||
@@ -262,9 +301,10 @@ async function glide(page, x, y) {
|
||||
const n = Math.max(8, Math.round(ms / 16));
|
||||
const from = { ...mouse };
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
const t = i / n,
|
||||
e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
// a slight arc, not a ruler line
|
||||
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
|
||||
const bend = Math.sin(Math.PI * t) * Math.min(40, d * 0.08);
|
||||
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
|
||||
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
|
||||
await page.mouse.move(nx, ny);
|
||||
@@ -275,30 +315,78 @@ async function glide(page, x, y) {
|
||||
|
||||
async function locate(page, step) {
|
||||
if (!step.target) throw new Error(`${step.do} needs a target`);
|
||||
let loc = page.locator(step.target);
|
||||
if (step.text) loc = loc.filter({ hasText: step.text });
|
||||
// dbx's UI kit renders its action buttons as elements carrying
|
||||
// data-slot="button" rather than the button tag, so a plan that says
|
||||
// "button" has to mean both
|
||||
const target = /^button$/i.test(step.target.trim()) ? 'button, [data-slot="button"]' : step.target;
|
||||
const all = page.locator(target);
|
||||
// a wish for text tucked into the selector ([aria-label="设置"], [title="Run"]):
|
||||
// take it out and treat it as the text filter, where the fallback can use it
|
||||
const attrText = /\[(?:aria-label|title|placeholder|data-slot|data-view)[*^$]?=~?"([^"]+)"/.exec(step.target)?.[1];
|
||||
const text = step.text || attrText || "";
|
||||
const visible = async (l) => {
|
||||
// the deepest match, not the first: a text filter matches a container and
|
||||
// everything inside it, and the click belongs on the innermost one — the
|
||||
// row, the label — not on the wrapper whose middle is often blank space
|
||||
const n = await l.count();
|
||||
let hit = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const one = l.nth(i);
|
||||
if (await one.isVisible()) return one;
|
||||
if (await one.isVisible()) hit = one;
|
||||
}
|
||||
return null;
|
||||
return hit;
|
||||
};
|
||||
const hit = await visible(loc);
|
||||
if (hit) return hit;
|
||||
const countVisible = async (l) => {
|
||||
const n = await l.count();
|
||||
let c = 0;
|
||||
for (let i = 0; i < n; i++) if (await l.nth(i).isVisible()) c++;
|
||||
return c;
|
||||
};
|
||||
// a text match is usually a container whose subtree carries the text: walk
|
||||
// down while exactly one visible child still contains it, so the click
|
||||
// lands on the row or the label, not on a wrapper whose middle is blank
|
||||
const deepest = async (hit, text) => {
|
||||
if (!hit || !text) return hit;
|
||||
try {
|
||||
const el = await hit.evaluateHandle((n, text) => {
|
||||
for (;;) {
|
||||
const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0);
|
||||
if (kids.length === 1) n = kids[0];
|
||||
else return n;
|
||||
}
|
||||
}, text);
|
||||
return el.asElement() || hit;
|
||||
} catch {
|
||||
return hit;
|
||||
}
|
||||
};
|
||||
let loc = text ? all.filter({ hasText: text }) : all;
|
||||
let hit = await visible(loc);
|
||||
// hasText matches an element's content, and a form field's content is its
|
||||
// value — empty until typed into — so input[placeholder="搜索..."] filters
|
||||
// itself out. The bare selector was right all along: trust it when exactly
|
||||
// one visible element answers to it.
|
||||
if (!hit && text && (await countVisible(all)) === 1) {
|
||||
loc = all;
|
||||
step = { ...step, text: "" };
|
||||
hit = await visible(loc);
|
||||
}
|
||||
if (hit) return deepest(hit, text);
|
||||
// the selector guessed a role or structure this app doesn't use (a tab, a
|
||||
// menuitem…): fall back to the one visible clickable thing carrying the
|
||||
// menuitem, a tree node…): fall back to the one visible thing carrying the
|
||||
// text — only when exactly one is on screen, so a wrong guess fails loud
|
||||
if (step.text) {
|
||||
const loose = page.locator('button, a, li, [role="menuitem"], [role="tab"], [role="button"], [class*="categor"], [class*="nav"] button, [class*="sidebar"] *').filter({ hasText: step.text });
|
||||
const loose = page.locator('button, a, li, span, div[class]:not([class=""]), [role="menuitem"], [role="tab"], [role="button"], [role="treeitem"], [class*="categor"] *, [class*="nav"] *, [class*="tree"] *, [class*="sidebar"] *').filter({ hasText: step.text });
|
||||
const seen = [];
|
||||
const n = await loose.count();
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let i = 0; i < n && seen.length <= 2; i++) {
|
||||
const one = loose.nth(i);
|
||||
if (await one.isVisible()) seen.push(one);
|
||||
}
|
||||
if (seen.length === 1) return seen[0];
|
||||
// at most two on screen (the same text in a parent and its child): take
|
||||
// the last, which in DOM order is the innermost; more means the wish for
|
||||
// the text is too vague — fail loud rather than click something wrong
|
||||
if (seen.length >= 1 && seen.length <= 2) return deepest(await seen.at(-1), step.text);
|
||||
}
|
||||
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
|
||||
}
|
||||
@@ -324,19 +412,22 @@ async function bringIntoView(page, loc) {
|
||||
const root = s === document.scrollingElement;
|
||||
const b = root ? clip : s.getBoundingClientRect();
|
||||
// what of it shows: inside the window and every view around it
|
||||
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
|
||||
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
|
||||
let top = Math.max(b.top, 0),
|
||||
bottom = Math.min(b.bottom, innerHeight);
|
||||
let left = Math.max(b.left, 0),
|
||||
right = Math.min(b.right, innerWidth);
|
||||
for (let p = s.parentElement; p && !root; p = p.parentElement) {
|
||||
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
|
||||
const pb = p.getBoundingClientRect();
|
||||
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
|
||||
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
|
||||
top = Math.max(top, pb.top);
|
||||
bottom = Math.min(bottom, pb.bottom);
|
||||
left = Math.max(left, pb.left);
|
||||
right = Math.min(right, pb.right);
|
||||
}
|
||||
const room = bottom - top - 2 * PAD;
|
||||
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
|
||||
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
|
||||
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
|
||||
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
|
||||
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)), x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
|
||||
}
|
||||
return out;
|
||||
}, PAD);
|
||||
@@ -362,17 +453,27 @@ async function pointAt(page, loc) {
|
||||
await bringIntoView(page, loc);
|
||||
const b = await loc.boundingBox();
|
||||
if (!b) throw new Error("the element has no box");
|
||||
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
|
||||
const x = b.x + Math.min(b.width / 2, 60),
|
||||
y = b.y + b.height / 2;
|
||||
await glide(page, x, y);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The app polls, so the network never goes quiet: loaded, and its first
|
||||
// paint in, is ready
|
||||
// The app polls, so the network never goes quiet: loaded, its toolbar and
|
||||
// the seeded connections painted in, is ready
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState("load").catch(() => {});
|
||||
await page.waitForFunction(() => document.querySelectorAll("button").length >= 2, null, { timeout: 15000 }).catch(() => {});
|
||||
await sleep(1200);
|
||||
await page
|
||||
.waitForFunction(
|
||||
() => {
|
||||
const txt = document.body.innerText || "";
|
||||
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
|
||||
},
|
||||
null,
|
||||
{ timeout: 20000 },
|
||||
)
|
||||
.catch(() => {});
|
||||
await sleep(1500);
|
||||
}
|
||||
|
||||
const caption = (page, text) => page.evaluate((t) => window.__uiPreviewCaption?.(t), text || "").catch(() => {});
|
||||
@@ -383,19 +484,26 @@ async function runStep(page, step, scene) {
|
||||
switch (step.do) {
|
||||
case "click": {
|
||||
const loc = await locate(page, step);
|
||||
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
|
||||
const label = (await loc.innerText().catch(() => "")) + " " + ((await loc.getAttribute("title").catch(() => "")) || "");
|
||||
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
|
||||
await pointAt(page, loc);
|
||||
await sleep(180);
|
||||
if (step.dblclick) {
|
||||
await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
await page.mouse.down();
|
||||
await sleep(60);
|
||||
await page.mouse.up();
|
||||
await sleep(120);
|
||||
await page.mouse.down();
|
||||
await sleep(60);
|
||||
await page.mouse.up();
|
||||
} else {
|
||||
await page.mouse.down();
|
||||
await sleep(90);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await sleep(700);
|
||||
// a click that opens something (a menu, the settings, a dialog) needs
|
||||
// its new DOM painted before the next step looks for it
|
||||
await sleep(1200);
|
||||
break;
|
||||
}
|
||||
case "hover": {
|
||||
@@ -406,12 +514,24 @@ async function runStep(page, step, scene) {
|
||||
case "type": {
|
||||
const loc = await locate(page, step);
|
||||
await pointAt(page, loc);
|
||||
await page.mouse.down(); await sleep(80); await page.mouse.up();
|
||||
await page.mouse.down();
|
||||
await sleep(80);
|
||||
await page.mouse.up();
|
||||
await loc.fill("");
|
||||
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
|
||||
await sleep(600);
|
||||
break;
|
||||
}
|
||||
case "upload": {
|
||||
// a file for an <input type=file>: the name is resolved inside SEED_DIR
|
||||
// (seed.mjs puts a sample CSV there) — the OS picker can't be driven
|
||||
const loc = step.target ? await locate(page, step) : page.locator('input[type="file"]').first();
|
||||
const dir = env("SEED_DIR", ".");
|
||||
const file = path.resolve(dir, String(step.value ?? step.file ?? "sample.csv"));
|
||||
await loc.setInputFiles(file);
|
||||
await sleep(800);
|
||||
break;
|
||||
}
|
||||
case "press":
|
||||
await page.keyboard.press(step.key || "Escape");
|
||||
await sleep(500);
|
||||
@@ -448,7 +568,8 @@ async function runStep(page, step, scene) {
|
||||
if (b) {
|
||||
// the target with room around it, never so small that a button
|
||||
// is shown without where it is
|
||||
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
|
||||
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)),
|
||||
h = Math.min(VIEW.height, Math.max(340, b.height + 48));
|
||||
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
|
||||
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
|
||||
clip = { x, y, width: w, height: h };
|
||||
@@ -458,7 +579,9 @@ async function runStep(page, step, scene) {
|
||||
} finally {
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.removeProperty("visibility"));
|
||||
}
|
||||
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width) });
|
||||
// a shot taken after a failed step in its scene shows the state the
|
||||
// plan actually reached, not the one the caption claims
|
||||
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width), warn: scene.derailed || undefined });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -482,7 +605,9 @@ async function main() {
|
||||
await lp.goto(BASE);
|
||||
await settle(lp);
|
||||
outlines.app = await lp.evaluate(outline);
|
||||
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
|
||||
} catch (e) {
|
||||
outlines.app = `(failed to load: ${e.message})`;
|
||||
}
|
||||
await look.close();
|
||||
|
||||
const MAXDIFF = 150e3;
|
||||
@@ -490,13 +615,18 @@ async function main() {
|
||||
log(`code context: ${code.length} characters`);
|
||||
const ask = [
|
||||
{ role: "system", content: GUIDE },
|
||||
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
|
||||
`=== outline: app ===\n${outlines.app}` +
|
||||
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
|
||||
{
|
||||
role: "user",
|
||||
content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` + `=== outline: app ===\n${outlines.app}` + (code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : ""),
|
||||
},
|
||||
];
|
||||
let plan;
|
||||
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await deepseek(ask); }
|
||||
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
|
||||
try {
|
||||
plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask);
|
||||
} catch (e) {
|
||||
plan = null;
|
||||
manifest.errors.push(`plan: ${e.message}`);
|
||||
}
|
||||
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
|
||||
manifest.summary = plan?.summary || "";
|
||||
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
|
||||
@@ -511,49 +641,101 @@ async function main() {
|
||||
if (plan.scenes.length) {
|
||||
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
|
||||
await rec.addInitScript({ path: path.join(here, "cursor.js") });
|
||||
const page = await rec.newPage();
|
||||
const began = Date.now();
|
||||
let lead = 0, repairs = 0, interactive = false;
|
||||
let repairs = 0,
|
||||
interactive = false;
|
||||
// one take per scene: every scene starts from the app as it opens, and
|
||||
// its video keeps only what happens once the app has settled — the
|
||||
// load never reaches the final cut, so a scene change reads as a cut,
|
||||
// not as the app reloading out of nowhere
|
||||
const takes = [];
|
||||
for (const s of plan.scenes.slice(0, 5)) {
|
||||
const scene = { title: s.title || "", shots: [] };
|
||||
const scene = { title: s.title || "", shots: [], derailed: false };
|
||||
manifest.scenes.push(scene);
|
||||
const page = await rec.newPage();
|
||||
const began = Date.now();
|
||||
let ready = 0;
|
||||
try {
|
||||
await caption(page, scene.title);
|
||||
await page.goto(BASE);
|
||||
await settle(page);
|
||||
if (!lead) lead = Math.max(0, (Date.now() - began) / 1000 - 0.4);
|
||||
await page.evaluate(([x, y]) => window.__uiPreviewAt?.(x, y), [mouse.x, mouse.y]);
|
||||
} catch (e) { manifest.errors.push(`${scene.title}: open: ${e.message}`); if (manifest.leak) break; continue; }
|
||||
ready = Date.now();
|
||||
} catch (e) {
|
||||
manifest.errors.push(`${scene.title}: open: ${e.message}`);
|
||||
await page.close().catch(() => {});
|
||||
if (manifest.leak) break;
|
||||
continue;
|
||||
}
|
||||
// the scene's title readable on the settled page for a beat, before
|
||||
// the first step's caption replaces it
|
||||
await caption(page, scene.title);
|
||||
await sleep(1100);
|
||||
let steps = (s.steps || []).slice(0, 25);
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (["click", "hover", "type", "press", "scroll"].includes(step.do)) interactive = true;
|
||||
try { await runStep(page, step, scene); }
|
||||
catch (e) {
|
||||
if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true;
|
||||
try {
|
||||
await runStep(page, step, scene);
|
||||
} catch (e) {
|
||||
if (manifest.leak) break;
|
||||
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
|
||||
log("step failed:", e.message);
|
||||
scene.derailed = true;
|
||||
// whatever the failed step (or an earlier stray one) left open — a
|
||||
// menu, a dropdown, a confirm dialog — swallows every click and
|
||||
// shows up in the shots that follow: clear it before going on
|
||||
await page.keyboard.press("Escape").catch(() => {});
|
||||
await sleep(400);
|
||||
// an active sidebar search keeps the tree filtered down to
|
||||
// nothing, starving every later lookup of its targets: empty it
|
||||
// the way a user would
|
||||
try {
|
||||
const box = page.locator(".connection-tree-search input").first();
|
||||
if ((await box.isVisible().catch(() => false)) && (await box.inputValue().catch(() => ""))) {
|
||||
await box.click({ timeout: 1500 });
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("Delete");
|
||||
await sleep(300);
|
||||
}
|
||||
} catch {}
|
||||
if (repairs >= 3) continue;
|
||||
repairs++;
|
||||
// the page as it is now, and what went wrong: the rest of the scene again
|
||||
try {
|
||||
const fix = await deepseek([...ask, { role: "assistant", content: JSON.stringify(plan) },
|
||||
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
|
||||
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
|
||||
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
|
||||
const fix = await askPlanner([
|
||||
...ask,
|
||||
{ role: "assistant", content: JSON.stringify(plan) },
|
||||
{
|
||||
role: "user",
|
||||
content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.`,
|
||||
},
|
||||
]);
|
||||
if (Array.isArray(fix.steps)) {
|
||||
steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)];
|
||||
log("repaired:", JSON.stringify(fix.steps));
|
||||
}
|
||||
} catch (e2) {
|
||||
manifest.errors.push(`repair: ${e2.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manifest.leak) break;
|
||||
if (manifest.leak) {
|
||||
await page.close().catch(() => {});
|
||||
break;
|
||||
}
|
||||
await caption(page, "");
|
||||
await sleep(600);
|
||||
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
|
||||
await page.close();
|
||||
}
|
||||
const video = await page.video().path();
|
||||
await rec.close();
|
||||
manifest.interactive = interactive;
|
||||
// a video when there's something to watch: an interaction
|
||||
if (!manifest.leak && interactive) {
|
||||
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", lead.toFixed(2), "-i", video, "-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4")]);
|
||||
if (!manifest.leak && interactive && takes.length) {
|
||||
// the takes joined, each already trimmed of its load
|
||||
const args = ["-y", "-loglevel", "error"];
|
||||
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
|
||||
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4"));
|
||||
execFileSync("ffmpeg", args);
|
||||
manifest.video = "preview.mp4";
|
||||
manifest.poster = await poster(browser);
|
||||
}
|
||||
@@ -562,7 +744,8 @@ async function main() {
|
||||
await browser.close();
|
||||
if (manifest.leak) {
|
||||
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
|
||||
manifest.scenes = []; manifest.video = manifest.poster = null;
|
||||
manifest.scenes = [];
|
||||
manifest.video = manifest.poster = null;
|
||||
}
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
|
||||
|
||||
@@ -52,6 +52,7 @@ if [ -z "$url" ]; then
|
||||
|
||||
node "$here/seed.mjs" dbs "$work/dbs"
|
||||
node "$here/seed.mjs" api "$url" "$work/dbs"
|
||||
export SEED_DIR="$work/dbs"
|
||||
fi
|
||||
|
||||
status=0
|
||||
|
||||
@@ -31,14 +31,22 @@ const DBS = [
|
||||
name: "商店 Shop (SQLite)",
|
||||
note: "订单、商品与客户示例库",
|
||||
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
|
||||
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
|
||||
warm: [
|
||||
"SELECT * FROM orders ORDER BY created_at DESC LIMIT 50",
|
||||
"SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC",
|
||||
"SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC",
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "dbx-analytics.sqlite",
|
||||
name: "分析 Analytics (SQLite)",
|
||||
note: "事件流与每日指标",
|
||||
tables: "events(5000 行,看分页)· daily_stats(90 天)",
|
||||
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
|
||||
warm: [
|
||||
"SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC",
|
||||
"SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14",
|
||||
"SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -112,7 +120,11 @@ async function api(base, p, init = {}) {
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedApi() {
|
||||
@@ -166,8 +178,14 @@ async function seedApi() {
|
||||
await api(base, "/api/saved-sql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
|
||||
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
|
||||
...s,
|
||||
connectionId: conns[i % conns.length].id,
|
||||
folderId: folder?.id || "seed-folder-daily",
|
||||
database: "main",
|
||||
orderIndex: i,
|
||||
openCount: 3 + i,
|
||||
createdAt: iso(36e5 * (70 - i)),
|
||||
updatedAt: iso(36e5 * (24 - i * 6)),
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
|
||||
}
|
||||
@@ -188,9 +206,17 @@ async function seedApi() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
entry: {
|
||||
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
|
||||
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
|
||||
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
|
||||
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`,
|
||||
connection_id: c.id,
|
||||
connection_name: c.name,
|
||||
database: "main",
|
||||
sql,
|
||||
executed_at: iso(36e5 * (2 + Math.random() * 60)),
|
||||
execution_time_ms: ms,
|
||||
success: ok,
|
||||
error: ok ? null : "demo entry: would have modified rows (not really run)",
|
||||
activity_kind: "query",
|
||||
source: "editor",
|
||||
},
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] history:", e.message));
|
||||
@@ -203,9 +229,15 @@ if (cmd === "dbs") {
|
||||
const dir = path.resolve(arg(3));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
python(dir);
|
||||
// a small importable file, for import/mapping dialogs (record.mjs's
|
||||
// "upload" step resolves names inside this directory)
|
||||
fs.writeFileSync(path.join(dir, "sample.csv"), "name,category,price,stock\n" + Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
|
||||
console.log("[ui-preview] databases written to", dir);
|
||||
} else if (cmd === "api") {
|
||||
seedApi().catch((e) => { console.error(e); process.exit(1); });
|
||||
seedApi().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
|
||||
process.exit(1);
|
||||
|
||||
@@ -1793,12 +1793,48 @@ jobs:
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ steps.release-token.outputs.token }}
|
||||
files: release/*
|
||||
body_path: RELEASE_NOTES.md
|
||||
make_latest: false
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.release-token.outputs.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Idempotent release publish: create the release only when missing and
|
||||
# upload only assets it does not have yet, in small spaced batches.
|
||||
# Re-uploading the full set on every retry trips GitHub secondary
|
||||
# rate limits and dropped connections strand the whole job.
|
||||
if gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
# --draft=false: a tag deleted mid-recovery leaves the release as a
|
||||
# draft, which /releases/tags then 404s for reuse jobs and users.
|
||||
gh release edit "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--notes-file RELEASE_NOTES.md --draft=false --latest=false >/dev/null
|
||||
else
|
||||
gh release create "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag --title "$TAG_NAME" \
|
||||
--notes-file RELEASE_NOTES.md --latest=false
|
||||
fi
|
||||
|
||||
mapfile -t existing < <(gh release view "$TAG_NAME" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name')
|
||||
declare -A have=()
|
||||
for name in "${existing[@]:-}"; do
|
||||
[ -n "$name" ] && have["$name"]=1
|
||||
done
|
||||
|
||||
missing=()
|
||||
for f in release/*; do
|
||||
[ -f "$f" ] || continue
|
||||
[ -n "${have["$(basename "$f")"]:-}" ] || missing+=("$f")
|
||||
done
|
||||
echo "release already has ${#existing[@]} assets; uploading ${#missing[@]} missing"
|
||||
|
||||
batch_size=5
|
||||
i=0
|
||||
while [ "$i" -lt "${#missing[@]}" ]; do
|
||||
gh release upload "$TAG_NAME" "${missing[@]:$i:$batch_size}" --repo "$GITHUB_REPOSITORY"
|
||||
i=$((i + batch_size))
|
||||
if [ "$i" -lt "${#missing[@]}" ]; then sleep 20; fi
|
||||
done
|
||||
echo "uploaded ${#missing[@]} assets in batches of $batch_size"
|
||||
|
||||
- name: Update agents-latest GitHub release
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
name: Cache Keepalive
|
||||
|
||||
# The shared Linux target caches (rust-test / packages / fmt-clippy, ~2-3 GB
|
||||
# each) are only saved from main runs. When the changes filter skips those
|
||||
# jobs for a few days, the entries go idle and GitHub's 10 GB LRU quota
|
||||
# evicts them, forcing fully cold builds for every PR (including fork PRs
|
||||
# that cannot access the S3 sccache backend). This workflow periodically
|
||||
# refreshes the main-saved caches on schedule.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Tuesday 01:30 UTC, ahead of typical PR activity hours.
|
||||
- cron: "30 1 * * 2"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cache-keepalive
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only
|
||||
|
||||
jobs:
|
||||
rust-test-keepalive:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
# sccache cannot reuse Cargo incremental artifacts, so avoid generating them in CI.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Deep async export tests exceed the Rust test harness's default thread stack on Linux.
|
||||
RUST_MIN_STACK: 8388608
|
||||
RUSTC_WRAPPER: sccache
|
||||
# "workspace" matches the full-coverage matrix group used on main pushes.
|
||||
RUST_TEST_GROUP: workspace
|
||||
RUST_FEATURE_MODE: full
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
update
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
-o Acquire::Retries=3 \
|
||||
-o Acquire::http::Timeout=30 \
|
||||
-o Acquire::https::Timeout=30 \
|
||||
install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev libsecret-1-dev
|
||||
|
||||
# Pin mold because runner image updates can change bundled C++ links.
|
||||
# See the ci.yml rust-test note for the pinned upstream build.
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
curl -fsSL https://github.com/rui314/mold/releases/download/v2.42.1/mold-2.42.1-x86_64-linux.tar.gz \
|
||||
| sudo tar -C /usr/local --strip-components=1 -xz
|
||||
mold --version
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
# Mirrors the ci.yml rust-test configuration: S3 when secrets are
|
||||
# available (they are on schedule runs), otherwise no cross-run caching.
|
||||
- name: Configure sccache
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
if [[ -n "$CACHE_BUCKET" ]]; then
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
fi
|
||||
# Keep the server alive through post-compile test/lint phases so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
# Also cache the C/C++ compilation of -sys crates. These jobs are
|
||||
# native Linux builds, so plain CC/CXX select the target compiler.
|
||||
echo "CC=${SCCACHE_PATH} cc"
|
||||
echo "CXX=${SCCACHE_PATH} c++"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Same key recipe as ci.yml rust-test so the restored/saved entry is the
|
||||
# one PR jobs look up. save-if is satisfied: scheduled runs are on main.
|
||||
- name: Rust cache
|
||||
env:
|
||||
RUST_FEATURE_MODE: ""
|
||||
RUST_TEST_GROUP: ""
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
prefix-key: v1-rust-complete
|
||||
workspaces: "./ -> target"
|
||||
shared-key: ci-rust-test-v2-x86_64-unknown-linux-gnu
|
||||
# Test linking dominates this job and sccache cannot cache those crate types.
|
||||
cache-workspace-crates: true
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
# mold -run wraps the whole process tree (see the ci.yml rust-test note).
|
||||
- name: Nextest
|
||||
run: |-
|
||||
mold -run node .github/scripts/ci-rust.mjs test "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Rust doc tests
|
||||
run: |-
|
||||
mold -run node .github/scripts/ci-rust.mjs doctest "$RUST_TEST_GROUP" "$RUST_FEATURE_MODE"
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: ${SCCACHE_PATH} --show-stats
|
||||
|
||||
prune-sccache-entries:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Prune stale sccache GHA cache entries
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The Windows jobs only enable the GHA sccache backend when S3
|
||||
# secrets are absent, but release workflows can still accumulate
|
||||
# thousands of small sccache/* entries that crowd the 10 GB quota.
|
||||
# The GHA backend keys on content hash, so entries nobody touched
|
||||
# for a week can be dropped without losing reachable warm caches.
|
||||
cutoff_days=7
|
||||
cutoff=$(date -u -d "-${cutoff_days} days" +%s)
|
||||
page=1
|
||||
ids=()
|
||||
while :; do
|
||||
resp=$(gh api "repos/${GITHUB_REPOSITORY}/actions/caches?key=sccache&per_page=100&page=${page}")
|
||||
count=$(printf '%s' "$resp" | jq '.actions_caches | length')
|
||||
[[ "$count" -eq 0 ]] && break
|
||||
while read -r id accessed; do
|
||||
[[ "$(date -u -d "$accessed" +%s)" -lt "$cutoff" ]] || continue
|
||||
ids+=("$id")
|
||||
done < <(printf '%s' "$resp" | jq -r '.actions_caches[] | "\(.id) \(.last_accessed_at)"')
|
||||
page=$((page + 1))
|
||||
done
|
||||
# Collect first, delete after: removing entries mid-pagination would
|
||||
# shift later pages and skip candidates.
|
||||
pruned=0
|
||||
for id in "${ids[@]}"; do
|
||||
if gh api -X DELETE "repos/${GITHUB_REPOSITORY}/actions/caches/${id}"; then
|
||||
pruned=$((pruned + 1))
|
||||
fi
|
||||
done
|
||||
echo "Pruned ${pruned} sccache cache entries idle for over ${cutoff_days} days."
|
||||
+223
-97
@@ -7,8 +7,9 @@ on:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && format('pr-author-{0}-{1}', github.event.pull_request.user.id, (endsWith(github.event.pull_request.number, '0') || endsWith(github.event.pull_request.number, '2') || endsWith(github.event.pull_request.number, '4') || endsWith(github.event.pull_request.number, '6') || endsWith(github.event.pull_request.number, '8')) && 'even' || 'odd') || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login == github.repository_owner }}
|
||||
queue: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner && 'max' || 'single' }}
|
||||
|
||||
env:
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only
|
||||
@@ -27,12 +28,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
@@ -76,12 +77,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
@@ -96,6 +97,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
NPM_CONFIG_LOGLEVEL: error
|
||||
# Sharded test runs across 900+ files exceed 4 GiB; public Linux runners provide 16 GiB RAM.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -107,12 +110,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
@@ -153,15 +156,32 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: GitHub script tests
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
env:
|
||||
FAST_CHECKS: ${{ needs.changes.outputs.fast }}
|
||||
run: |
|
||||
tests=()
|
||||
for file in .github/scripts/*.test.mjs; do
|
||||
if [[ "$FAST_CHECKS" == true && "${file##*/}" == ci-* ]]; then
|
||||
continue
|
||||
fi
|
||||
tests+=("$file")
|
||||
done
|
||||
node --test "${tests[@]}"
|
||||
|
||||
- name: Docker mirror config tests
|
||||
run: python3 .github/scripts/configure-docker-mirror.test.py
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/install-mcp-powershell.test.ps1
|
||||
|
||||
- name: Windows installer bundling tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/bundle-win7-installer.test.ps1
|
||||
|
||||
packages:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.packages == 'true'
|
||||
@@ -178,12 +198,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Install system dependencies
|
||||
@@ -217,7 +237,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -287,14 +307,16 @@ jobs:
|
||||
|
||||
windows-standard-check:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.windows_win7_bundle == 'true'
|
||||
if: needs.changes.outputs.windows_standard == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
|
||||
# Linux target caches when thousands of GHA sccache entries pile up.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
SCCACHE_GHA_VERSION: windows-standard-v1
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -306,6 +328,30 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
# Keep the server alive through post-compile checks so
|
||||
# "Show sccache stats" reflects the real counters.
|
||||
echo "SCCACHE_IDLE_TIMEOUT=0"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check standard Windows dependency path
|
||||
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
|
||||
|
||||
@@ -319,17 +365,22 @@ jobs:
|
||||
if: needs.changes.outputs.windows_win7_bundle == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_PROFILE_RELEASE_LTO: "off"
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "8"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
|
||||
# Linux target caches when thousands of GHA sccache entries pile up.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
# Single source for the job's nightly pin: the toolchain step and the
|
||||
# prebuilt AWS-LC cache key must move together.
|
||||
# prebuilt AWS-LC artifact name must move together.
|
||||
WIN7_NIGHTLY_TOOLCHAIN: nightly-2026-07-22
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -337,12 +388,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Install frontend dependencies
|
||||
@@ -358,16 +409,47 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Restore prebuilt Win7 AWS-LC
|
||||
id: aws-lc-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/win7-aws-lc-install
|
||||
# The fixture manifest and lockfile define the prebuilt AWS-LC artifact.
|
||||
key: win7-aws-lc-v3-${{ runner.os }}-${{ env.WIN7_NIGHTLY_TOOLCHAIN }}-${{ hashFiles('.github/fixtures/win7-aws-lc-cache/Cargo.toml', '.github/fixtures/win7-aws-lc-cache/Cargo.lock') }}
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Win7 AWS-LC cache
|
||||
if: steps.aws-lc-cache.outputs.cache-hit != 'true'
|
||||
- name: Find prebuilt Win7 AWS-LC artifact
|
||||
id: aws-lc-artifact
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WIN7_PREBUILT_ARTIFACT_NAME: win7-aws-lc-v3-${{ runner.os }}-${{ env.WIN7_NIGHTLY_TOOLCHAIN }}-${{ hashFiles('.github/fixtures/win7-aws-lc-cache/Cargo.toml', '.github/fixtures/win7-aws-lc-cache/Cargo.lock') }}
|
||||
run: node .github/scripts/find-win7-prebuilt-artifact.mjs
|
||||
|
||||
- name: Download prebuilt Win7 AWS-LC artifact
|
||||
if: steps.aws-lc-artifact.outputs.run_id != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.aws-lc-artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/win7-aws-lc-install
|
||||
run-id: ${{ steps.aws-lc-artifact.outputs.run_id }}
|
||||
repository: ${{ github.repository }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Build prebuilt Win7 AWS-LC
|
||||
if: steps.aws-lc-artifact.outputs.run_id == ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$timer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
@@ -429,7 +511,7 @@ jobs:
|
||||
}
|
||||
$bindingLines | Set-Content -LiteralPath $bindingOutput -Encoding utf8NoBOM
|
||||
$timer.Stop()
|
||||
"AWS-LC cache build: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" >> $env:GITHUB_STEP_SUMMARY
|
||||
"AWS-LC prebuilt build: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" >> $env:GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ runner.temp }}/win7-aws-lc-build
|
||||
|
||||
@@ -445,24 +527,44 @@ jobs:
|
||||
)
|
||||
foreach ($relativePath in $requiredFiles) {
|
||||
if (!(Test-Path (Join-Path $installRoot $relativePath))) {
|
||||
Write-Error "The AWS-LC cache does not contain $relativePath."
|
||||
Write-Error "The AWS-LC artifact does not contain $relativePath."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
"AWS_LC_SYS_SYSTEM_DIR=$installRoot" >> $env:GITHUB_ENV
|
||||
"AWS_LC_SYS_USE_SYSTEM=1" >> $env:GITHUB_ENV
|
||||
"AWS_LC_SYS_STATIC=1" >> $env:GITHUB_ENV
|
||||
"AWS-LC cache hit: ${{ steps.aws-lc-cache.outputs.cache-hit }}" >> $env:GITHUB_STEP_SUMMARY
|
||||
"AWS-LC artifact hit: ${{ steps.aws-lc-artifact.outputs.run_id != '' }}" >> $env:GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Restore prebuilt Win7 OpenSSL
|
||||
id: openssl-cache
|
||||
uses: actions/cache@v4
|
||||
- name: Upload prebuilt Win7 AWS-LC on main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.aws-lc-artifact.outputs.run_id == ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/win7-openssl-install
|
||||
key: win7-openssl-v1-${{ runner.os }}-${{ env.WIN7_NIGHTLY_TOOLCHAIN }}-${{ hashFiles('.github/fixtures/win7-openssl-cache/Cargo.toml', '.github/fixtures/win7-openssl-cache/Cargo.lock') }}
|
||||
name: ${{ steps.aws-lc-artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/win7-aws-lc-install/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Build Win7 OpenSSL cache
|
||||
if: steps.openssl-cache.outputs.cache-hit != 'true'
|
||||
- name: Find prebuilt Win7 OpenSSL artifact
|
||||
id: openssl-artifact
|
||||
shell: pwsh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
WIN7_PREBUILT_ARTIFACT_NAME: win7-openssl-v1-${{ runner.os }}-${{ env.WIN7_NIGHTLY_TOOLCHAIN }}-${{ hashFiles('.github/fixtures/win7-openssl-cache/Cargo.toml', '.github/fixtures/win7-openssl-cache/Cargo.lock') }}
|
||||
run: node .github/scripts/find-win7-prebuilt-artifact.mjs
|
||||
|
||||
- name: Download prebuilt Win7 OpenSSL artifact
|
||||
if: steps.openssl-artifact.outputs.run_id != ''
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.openssl-artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/win7-openssl-install
|
||||
run-id: ${{ steps.openssl-artifact.outputs.run_id }}
|
||||
repository: ${{ github.repository }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Build prebuilt Win7 OpenSSL
|
||||
if: steps.openssl-artifact.outputs.run_id == ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$timer = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
@@ -485,7 +587,7 @@ jobs:
|
||||
New-Item -ItemType Directory -Path $installRoot -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $install.FullName "*") -Destination $installRoot -Recurse -Force
|
||||
$timer.Stop()
|
||||
"OpenSSL cache build: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" >> $env:GITHUB_STEP_SUMMARY
|
||||
"OpenSSL prebuilt build: $([math]::Round($timer.Elapsed.TotalSeconds, 1)) seconds" >> $env:GITHUB_STEP_SUMMARY
|
||||
env:
|
||||
CARGO_TARGET_DIR: ${{ runner.temp }}/win7-openssl-build
|
||||
|
||||
@@ -494,21 +596,30 @@ jobs:
|
||||
run: |
|
||||
$installRoot = Join-Path $env:RUNNER_TEMP "win7-openssl-install"
|
||||
if (!(Test-Path (Join-Path $installRoot "include/openssl/ssl.h"))) {
|
||||
Write-Error "The OpenSSL cache does not contain ssl.h."
|
||||
Write-Error "The OpenSSL artifact does not contain ssl.h."
|
||||
exit 1
|
||||
}
|
||||
if (!(Get-ChildItem (Join-Path $installRoot "lib") -Filter "*ssl*.lib")) {
|
||||
Write-Error "The OpenSSL cache does not contain an SSL library."
|
||||
Write-Error "The OpenSSL artifact does not contain an SSL library."
|
||||
exit 1
|
||||
}
|
||||
if (!(Get-ChildItem (Join-Path $installRoot "lib") -Filter "*crypto*.lib")) {
|
||||
Write-Error "The OpenSSL cache does not contain a crypto library."
|
||||
Write-Error "The OpenSSL artifact does not contain a crypto library."
|
||||
exit 1
|
||||
}
|
||||
"OPENSSL_DIR=$installRoot" >> $env:GITHUB_ENV
|
||||
"OPENSSL_NO_VENDOR=1" >> $env:GITHUB_ENV
|
||||
"OPENSSL_STATIC=1" >> $env:GITHUB_ENV
|
||||
"OpenSSL cache hit: ${{ steps.openssl-cache.outputs.cache-hit }}" >> $env:GITHUB_STEP_SUMMARY
|
||||
"OpenSSL artifact hit: ${{ steps.openssl-artifact.outputs.run_id != '' }}" >> $env:GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload prebuilt Win7 OpenSSL on main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.openssl-artifact.outputs.run_id == ''
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.openssl-artifact.outputs.name }}
|
||||
path: ${{ runner.temp }}/win7-openssl-install/
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
@@ -553,19 +664,7 @@ jobs:
|
||||
|
||||
- name: Bundle Windows 7 fixed-runtime test installer with zlib
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc `
|
||||
--config src-tauri/tauri.webview2-win7-fixed.conf.json `
|
||||
--config .github/fixtures/win7-nsis-zlib-config.json
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
exit 1
|
||||
}
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
run: ./.github/scripts/bundle-win7-installer.ps1
|
||||
|
||||
- name: Audit Windows 7 installer contents
|
||||
shell: pwsh
|
||||
@@ -714,7 +813,9 @@ jobs:
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/target/duckdb-driver
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
# Prefer S3 when available; GitHub's 10 GB cache quota evicts the shared
|
||||
# Linux target caches when thousands of GHA sccache entries pile up.
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
SCCACHE_GHA_VERSION: duckdb-windows-v1
|
||||
# libduckdb-sys uses cc-rs for its bundled C++ sources.
|
||||
CC: "sccache cl.exe"
|
||||
@@ -741,6 +842,27 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build DuckDB Windows driver
|
||||
shell: pwsh
|
||||
working-directory: agents/drivers/duckdb
|
||||
@@ -869,6 +991,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo timeout --kill-after=30s 10m apt-get \
|
||||
@@ -944,7 +1076,7 @@ jobs:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -1077,7 +1209,7 @@ jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend == 'true' && steps.plan.outputs.contributor_snapshot_only != 'true' }}
|
||||
packages: ${{ steps.filter.outputs.packages }}
|
||||
rust: ${{ steps.plan.outputs.rust }}
|
||||
rust_full: ${{ steps.plan.outputs.rust_full }}
|
||||
@@ -1086,7 +1218,8 @@ jobs:
|
||||
agents: ${{ steps.plan.outputs.agents }}
|
||||
duckdb_windows: ${{ steps.filter.outputs.duckdb_windows == 'true' || steps.plan.outputs.duckdb_windows == 'true' }}
|
||||
nix: ${{ steps.filter.outputs.nix }}
|
||||
windows_win7_bundle: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
windows_standard: ${{ steps.plan.outputs.windows_standard }}
|
||||
windows_win7_bundle: ${{ steps.plan.outputs.windows_win7_bundle }}
|
||||
windows_win7_candidate: ${{ steps.plan.outputs.windows_win7_candidate }}
|
||||
windows_win7_affected_packages: ${{ steps.plan.outputs.windows_win7_affected_packages }}
|
||||
windows_win7_reasons: ${{ steps.plan.outputs.windows_win7_reasons }}
|
||||
@@ -1105,14 +1238,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Fetch change base
|
||||
id: change-base
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then
|
||||
BASE_SHA="$(node .github/scripts/ci-change-base.mjs)"
|
||||
if [[ -z "$BASE_SHA" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||
@@ -1122,7 +1256,9 @@ jobs:
|
||||
uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
token: ''
|
||||
base: ${{ steps.change-base.outputs.sha }}
|
||||
ref: ${{ github.sha }}
|
||||
filters: |
|
||||
frontend:
|
||||
- 'apps/desktop/**'
|
||||
@@ -1205,35 +1341,16 @@ jobs:
|
||||
- 'flake.lock'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/update-nix-pnpm-hash.yml'
|
||||
windows_win7_bundle:
|
||||
- '.github/scripts/assert-win7-pe-compat.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/assert-win7-installer-content.ps1'
|
||||
- '.github/scripts/assert-webview2-win7-runtime.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-loader.ps1'
|
||||
- '.github/scripts/prepare-webview2-win7-runtime.ps1'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/release.yml'
|
||||
- 'src-tauri/tauri.webview2-win7-fixed.conf.json'
|
||||
- 'src-tauri/build.rs'
|
||||
- 'src-tauri/Cargo.toml'
|
||||
- 'src-tauri/windows/nsis/**'
|
||||
- 'src-tauri/src/commands/update.rs'
|
||||
- 'crates/dbx-core/Cargo.toml'
|
||||
- 'crates/dbx-drivers/**'
|
||||
- 'crates/dbx-driver-*/**'
|
||||
- 'crates/dbx-platform/**'
|
||||
- 'crates/dbx-core/src/host/update.rs'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'vendor/ctor/**'
|
||||
- 'vendor/dirs-sys/**'
|
||||
- 'vendor/pageant/**'
|
||||
- 'vendor/webview2-com-sys/**'
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- 'docs/scripts/contributor-snapshot.mjs'
|
||||
- 'docs/scripts/update-contributors.mjs'
|
||||
- 'docs/public/install-mcp*'
|
||||
- '.github/workflows/contributors.yml'
|
||||
- '.github/workflows/issue-claim.yml'
|
||||
- '.github/workflows/notify.yml'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
- '.github/workflows/spam-comment-guard.yml'
|
||||
- '.github/workflows/mcp-release.yml'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
- '.github/workflows/plugin-release-reusable.yml'
|
||||
@@ -1246,7 +1363,6 @@ jobs:
|
||||
BASE_SHA: ${{ steps.change-base.outputs.sha }}
|
||||
RUST_CHANGED: ${{ steps.filter.outputs.rust }}
|
||||
AGENTS_CHANGED: ${{ steps.filter.outputs.agents }}
|
||||
WIN7_CURRENT: ${{ steps.filter.outputs.windows_win7_bundle }}
|
||||
run: node .github/scripts/ci-plan.mjs
|
||||
|
||||
agents:
|
||||
@@ -1283,7 +1399,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
- name: CI planner and gate tests
|
||||
run: node --test .github/scripts/ci-*.test.mjs
|
||||
- name: Core architecture contracts
|
||||
@@ -1293,7 +1409,7 @@ jobs:
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.27.0
|
||||
version: 10.34.7
|
||||
- name: Install generator dependencies
|
||||
if: needs.changes.outputs.rust == 'true'
|
||||
run: pnpm --filter dbx... install --frozen-lockfile --ignore-scripts
|
||||
@@ -1319,7 +1435,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -1439,7 +1555,7 @@ jobs:
|
||||
cache-on-failure: false
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
# mold -run wraps the whole process tree (see the packages job note);
|
||||
@@ -1492,7 +1608,7 @@ jobs:
|
||||
run: bash ../.github/scripts/ci-agent-go.sh "$DRIVER" "$BINARY" "$RACE"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_go) }}
|
||||
agent-integration:
|
||||
needs: [changes, fast-checks, agent-checks]
|
||||
@@ -1503,6 +1619,16 @@ jobs:
|
||||
working-directory: agents
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Configure public Docker Hub cache
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
source /etc/os-release
|
||||
[[ "$ID" == ubuntu ]] || exit 0
|
||||
sudo python3 .github/scripts/configure-docker-mirror.py /etc/docker/daemon.json --validate
|
||||
timeout 30s sudo systemctl restart docker
|
||||
timeout 15s docker info --format '{{json .RegistryConfig.Mirrors}}' \
|
||||
| python3 -c 'import json, sys; assert any(x.rstrip("/") == "https://mirror.gcr.io" for x in json.load(sys.stdin))'
|
||||
- name: Setup RocketMQ server Java
|
||||
if: matrix.scenario == 'rocketmq'
|
||||
uses: actions/setup-java@v6
|
||||
@@ -1520,7 +1646,7 @@ jobs:
|
||||
if: matrix.driver == 'tdengine'
|
||||
- name: Install nextest
|
||||
if: matrix.driver == 'tdengine'
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- name: TDengine Rust cache
|
||||
@@ -1540,7 +1666,7 @@ jobs:
|
||||
name: agent-integration (${{ matrix.scenario }}, ${{ matrix.version }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
max-parallel: 4
|
||||
matrix: ${{ fromJSON(needs.changes.outputs.agent_integration) }}
|
||||
ci:
|
||||
needs:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Update Contributors
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 18 * * *"
|
||||
- cron: "0 18 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Validate database environment tooling
|
||||
run: |
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Autofill missing i18n entries
|
||||
env:
|
||||
|
||||
@@ -16,7 +16,11 @@ permissions:
|
||||
jobs:
|
||||
claim:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
if: >-
|
||||
${{ !github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(contains(github.event.comment.body, '/claim') ||
|
||||
contains(github.event.comment.body, '/unclaim') ||
|
||||
contains(github.event.comment.body, '/close')) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Triage issue labels and title
|
||||
run: node .github/scripts/label-issue-database.mjs
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Synchronize database labels
|
||||
run: node .github/scripts/label-issue-database.mjs
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
run: cargo check -p dbx-mcp -p dbx-cli --no-default-features
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3 # v2.68.13
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b # v2.68.13
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -440,7 +440,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Publish platform package
|
||||
@@ -453,7 +453,22 @@ jobs:
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
exit 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
fi
|
||||
|
||||
publish-cli:
|
||||
name: Publish @dbx-app/cli
|
||||
@@ -471,7 +486,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
@@ -674,7 +689,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Sign macOS MCP binary
|
||||
@@ -709,6 +724,25 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# npm can accept a publish but leave the version stuck in a staged
|
||||
# (invisible, blocks republication) state; the only recovery is
|
||||
# to drop the staged version and publish again.
|
||||
npm_publish_with_staged_recovery() {
|
||||
local publish_log="${RUNNER_TEMP}/npm-publish.log"
|
||||
if ! npm publish "$@" --access public --provenance 2>&1 | tee "$publish_log"; then
|
||||
if ! grep -q "previously staged version" "$publish_log"; then
|
||||
return 1
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} became visible despite the error; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::warning::npm holds ${{ matrix.package-name }}@${VERSION} in a staged (invisible) state; dropping it and retrying."
|
||||
npm unpublish "${{ matrix.package-name }}@${VERSION}" --force
|
||||
npm publish "$@" --access public --provenance
|
||||
return
|
||||
fi
|
||||
}
|
||||
if [[ "${{ runner.os }}" == "macOS" ]]; then
|
||||
work_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
@@ -727,14 +761,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cmp "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}" "$work_dir/package/bin/${{ matrix.binary }}"
|
||||
npm publish "$tarball" --access public --provenance --ignore-scripts
|
||||
npm_publish_with_staged_recovery "$tarball" --ignore-scripts
|
||||
exit 0
|
||||
fi
|
||||
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
|
||||
npm_publish_with_staged_recovery "./packages/${{ matrix.package-dir }}"
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
@@ -757,7 +791,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
@@ -811,7 +845,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Build native release archives from npm packages
|
||||
@@ -919,7 +953,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Build native CLI release archives from npm packages
|
||||
|
||||
@@ -3,13 +3,15 @@ name: Notifications
|
||||
on:
|
||||
# issues:
|
||||
# types: [opened]
|
||||
pull_request_target:
|
||||
types: [opened, closed]
|
||||
# pull_request_target:
|
||||
# types: [closed]
|
||||
workflow_dispatch:
|
||||
# Release 不在此通知:桌面正式版由 publish-packages.yml 在转正后发飞书卡片;
|
||||
# 组件包(agents-v*/packages-v*)发布不进群。
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build message
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -130,7 +130,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install nextest
|
||||
uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
|
||||
@@ -329,7 +329,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Publish platform package
|
||||
@@ -357,7 +357,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Prepare development runtime build dependencies
|
||||
@@ -412,7 +412,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -11,8 +11,22 @@ on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
test:
|
||||
needs: checks
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -29,11 +43,9 @@ jobs:
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
- run: npm test --prefix plugins/sdk/dev-host
|
||||
timeout-minutes: 5
|
||||
- uses: taiki-e/install-action@9114bf4d891761788c546334fd37538eae1bf8b3
|
||||
- uses: taiki-e/install-action@a3324fb0eb94b8230ec968c3389c1b7929fc2f3b
|
||||
with:
|
||||
tool: cargo-nextest@0.9.137
|
||||
- run: cargo nextest run --locked --manifest-path plugins/sdk/cli/Cargo.toml --no-fail-fast
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: Pull Request Labels
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
# Temporarily disabled (2026-10-10): informational labels only, no
|
||||
# mechanical consumers, fires on every PR event. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pull-request-labels-${{ github.event.pull_request.number }}
|
||||
@@ -50,7 +53,7 @@ jobs:
|
||||
if: steps.verify_head.outputs.stale != 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Synchronize pull request labels
|
||||
if: steps.verify_head.outputs.stale != 'true'
|
||||
|
||||
@@ -581,7 +581,7 @@ jobs:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_ENABLED: ${{ secrets.SCCACHE_S3_BUCKET == '' && 'true' || 'false' }}
|
||||
SCCACHE_GHA_VERSION: win7-webview2-1.0.902.49-v1
|
||||
SCCACHE_IDLE_TIMEOUT: "0"
|
||||
VSIGN_CERT_HASH: ${{ vars.VSIGN_CERT_HASH }}
|
||||
@@ -612,6 +612,27 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Configure S3 sccache
|
||||
if: env.SCCACHE_GHA_ENABLED != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_BUCKET: ${{ secrets.SCCACHE_S3_BUCKET }}
|
||||
CACHE_ENDPOINT: ${{ secrets.SCCACHE_S3_ENDPOINT }}
|
||||
CACHE_REGION: ${{ secrets.SCCACHE_S3_REGION }}
|
||||
CACHE_KEY_PREFIX: ${{ secrets.SCCACHE_S3_KEY_PREFIX }}
|
||||
CACHE_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
|
||||
CACHE_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
{
|
||||
echo "SCCACHE_BUCKET=${CACHE_BUCKET}"
|
||||
echo "SCCACHE_ENDPOINT=${CACHE_ENDPOINT}"
|
||||
echo "SCCACHE_REGION=${CACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${CACHE_KEY_PREFIX}"
|
||||
echo "SCCACHE_S3_USE_SSL=true"
|
||||
echo "AWS_ACCESS_KEY_ID=${CACHE_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${CACHE_SECRET_ACCESS_KEY}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Backfill similar issue suggestions
|
||||
run: node .github/scripts/backfill-similar-issues.mjs
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
|
||||
- name: Suggest similar issues
|
||||
run: node .github/scripts/suggest-similar-issues.mjs
|
||||
|
||||
@@ -12,7 +12,12 @@ jobs:
|
||||
delete-malicious-link:
|
||||
runs-on: ubuntu-latest
|
||||
# 跳过机器人与仓库 owner,避免误删维护者的正当评论
|
||||
if: ${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner }}
|
||||
if: >-
|
||||
${{ github.event.comment.user.type != 'Bot' && github.event.comment.user.login != github.repository_owner &&
|
||||
(contains(github.event.comment.body, '.zip') ||
|
||||
contains(github.event.comment.body, '.7z') ||
|
||||
contains(github.event.comment.body, '.exe') ||
|
||||
contains(github.event.comment.body, '.scr')) }}
|
||||
env:
|
||||
# owner PAT:GITHUB_TOKEN 非 admin 删不掉别人的评论,必须用 owner 令牌
|
||||
GH_TOKEN: ${{ secrets.PROJECT_AUTOMATION_TOKEN }}
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: 22.23.3
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Java
|
||||
|
||||
@@ -7,7 +7,7 @@ name: UI preview
|
||||
#
|
||||
# Pull requests come from forks, so this runs on pull_request_target with
|
||||
# the scripts from main. The PR's code is only built and run in the record
|
||||
# job, which holds the DeepSeek key (environment ui-preview, no approval
|
||||
# job, which holds the planner's API key (environment ui-preview, no approval
|
||||
# asked: any PR's code can read it, so the key there is one with a low
|
||||
# limit) and nothing that can write to the repo. The publish job, which can
|
||||
# write, never runs the PR's code.
|
||||
@@ -19,8 +19,10 @@ name: UI preview
|
||||
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
# Temporarily disabled (2026-10-10) while jobs queue without a runner;
|
||||
# manual dispatch below still works. Uncomment to re-enable.
|
||||
# pull_request_target:
|
||||
# types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
@@ -109,6 +111,8 @@ jobs:
|
||||
|
||||
# the frontend, from the PR, for both lanes
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: pr/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -147,10 +151,13 @@ jobs:
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Record
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
PLAN_API_URL: https://api.atlascloud.ai/v1/chat/completions
|
||||
PLAN_API_KEY: ${{ secrets.PLAN_API_KEY }}
|
||||
PLAN_MODEL: ${{ vars.UI_PREVIEW_PLAN_MODEL || 'openai/gpt-5.4-mini' }}
|
||||
DIFF_FILE: ${{ github.workspace }}/pr-info/pr.diff
|
||||
PR_BODY_FILE: ${{ github.workspace }}/pr-info/body.txt
|
||||
DIST_DIR: ${{ github.workspace }}/pr/dist
|
||||
SRC_DIR: ${{ github.workspace }}/pr
|
||||
run: PR_TITLE="$(cat pr-info/title.txt)" ci/.github/ui-preview/run.sh out
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -83,6 +83,7 @@ issue-assets/
|
||||
outputs/
|
||||
target-ai-write-confirmation/
|
||||
.serena/
|
||||
.mimosa/
|
||||
|
||||
# Code review tool artifacts
|
||||
review_diff.json
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ If you are not sure what to pick, choose an issue with clear reproduction steps,
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 22.13.0
|
||||
- pnpm 10.27.0
|
||||
- pnpm 10.34.7
|
||||
- Rust >= 1.88
|
||||
- Make
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 22.13.0
|
||||
- pnpm 10.27.0
|
||||
- pnpm 10.34.7
|
||||
- Rust >= 1.88
|
||||
- Make
|
||||
|
||||
|
||||
Generated
+48
-17
@@ -262,6 +262,12 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arcstr"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -1527,9 +1533,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -2137,7 +2143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx"
|
||||
version = "0.6.36"
|
||||
version = "0.6.49"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -2153,6 +2159,7 @@ dependencies = [
|
||||
"font-kit",
|
||||
"futures",
|
||||
"grep",
|
||||
"gtk",
|
||||
"ignore",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
@@ -2195,6 +2202,8 @@ dependencies = [
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"webview2-com",
|
||||
"windows-core 0.61.2",
|
||||
"windows-sys 0.61.2",
|
||||
"wry",
|
||||
"zip",
|
||||
@@ -2220,7 +2229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-cli"
|
||||
version = "0.4.109"
|
||||
version = "0.4.125"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dbx-core",
|
||||
@@ -2400,6 +2409,7 @@ dependencies = [
|
||||
"dbx-sql-core",
|
||||
"dbx-types",
|
||||
"deadpool-postgres",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"log",
|
||||
"openssl",
|
||||
@@ -2427,7 +2437,9 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dbx-driver-support",
|
||||
"dbx-types",
|
||||
"futures-util",
|
||||
"redis",
|
||||
"rustls 0.23.43",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2556,7 +2568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.109"
|
||||
version = "0.4.125"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -2740,7 +2752,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dbx-web"
|
||||
version = "0.6.36"
|
||||
version = "0.6.49"
|
||||
dependencies = [
|
||||
"aes-gcm 0.10.3",
|
||||
"argon2",
|
||||
@@ -5891,7 +5903,7 @@ dependencies = [
|
||||
"darling 0.23.0",
|
||||
"heck 0.5.0",
|
||||
"manyhow",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5946,7 +5958,7 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"mysql-common-derive",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"rust_decimal",
|
||||
@@ -6097,7 +6109,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
@@ -6115,6 +6127,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -6171,7 +6193,7 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
@@ -7626,22 +7648,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "0.32.7"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "014cc767fefab6a3e798ca45112bccad9c6e0e218fbd49720042716c73cfef44"
|
||||
checksum = "ed5b98ae99461e02895c3a96a25dbe4d852a3d5e5b815d31d5d9e4b5344d99e9"
|
||||
dependencies = [
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"crc16",
|
||||
"futures-sink",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"itoa",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"num-bigint 0.5.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rustls 0.23.43",
|
||||
"rustls-native-certs 0.8.4",
|
||||
"ryu",
|
||||
@@ -7651,6 +7675,7 @@ dependencies = [
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"url",
|
||||
"xxhash-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8054,7 +8079,7 @@ dependencies = [
|
||||
"md5",
|
||||
"ml-kem",
|
||||
"module-lattice",
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
@@ -9054,7 +9079,7 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-bigint 0.4.8",
|
||||
"num-traits",
|
||||
"thiserror 2.0.19",
|
||||
"time",
|
||||
@@ -12111,6 +12136,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xxhash-rust"
|
||||
version = "0.8.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||
|
||||
[[package]]
|
||||
name = "yeslogic-fontconfig-sys"
|
||||
version = "6.0.1"
|
||||
|
||||
@@ -69,6 +69,17 @@
|
||||
## ❤️ Sponsors
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer is an Open-source Privileged Access Management (PAM) platform with AI-powered capabilities, providing DevOps and IT teams a unified workspace to securely access SSH, RDP, Kubernetes, databases, websites, RemoteApp, VirtualApp, and more.
|
||||
<a href="https://github.com/jumpserver/jumpserver" target="_blank">Visit JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -80,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">Visit RainYun</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -124,6 +124,17 @@
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">Visit HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI is a developer-focused API platform for image, video, audio, and text models — GPT Image 2.5 (Flare & Sunburst), OpenAI-compatible text endpoints, and Remote MCP/Agent Skills for Claude Code, Cursor, and more. Pay as you go with no minimum spend; new users get 200 Credits on signup, plus 10% bonus Credits on top-ups.
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-en" target="_blank">Visit HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -135,6 +146,40 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">Visit UCloud</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode is a stable, efficient API relay for mainstream AI models. Access them with one API endpoint and one API key — automatic failover keeps access fast and reliable, with dedicated high-speed routes for Codex and Claude Code. Get started with $1 in free credits, a first top-up discount, and up to 80% off eligible routes; pay in RMB with no currency conversion markups or extra top-up fees.
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">Visit PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
TrustAsia provides cloud-based code signing service for DBX, enabling trusted software through automated CI/CD builds.
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">Visit TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -434,6 +479,16 @@ together with `dbx.db`. Persisting `/app/data` is therefore sufficient for a
|
||||
normal Docker deployment. This key protects the database contents, but cannot
|
||||
protect the whole data volume if the volume itself is copied or exposed.
|
||||
|
||||
Windows portable builds are the exception to the desktop platform-key rule.
|
||||
To remain portable across computers, they keep the local storage key at
|
||||
`data/.dbx/secret.key`; move and back up the complete `data` directory. If an
|
||||
older portable profile already contains ciphertext protected by Windows
|
||||
Credential Manager, launch the new build once on the original computer so DBX
|
||||
can verify and copy that key to the USB drive. If the original computer or key
|
||||
is gone, the ciphertext cannot be recovered automatically; restore the original
|
||||
key or a pre-migration backup. Keeping the key beside the database means anyone
|
||||
who obtains or copies the whole drive can also decrypt its credentials.
|
||||
|
||||
For production deployments, replace the managed key with a Docker/Kubernetes
|
||||
Secret by setting `DBX_SECRET_KEY_FILE`, or provide `DBX_SECRET_KEY` through a
|
||||
secret manager. Explicit keys take precedence and must never be rotated while
|
||||
@@ -463,9 +518,10 @@ directory in Desktop or Web and complete its upgrade wizard. For headless
|
||||
hosts without a platform credential store, configure the persistent key.
|
||||
|
||||
Cross-device exports use a separate sync passphrase and never contain the
|
||||
local storage key. A direct `dbx.db` copy is not a cross-platform sync method:
|
||||
platform keys do not move with the database. Use encrypted export/import so
|
||||
the target device stores credentials using its own local key.
|
||||
local storage key. For installed builds, or when copying only `dbx.db`, platform
|
||||
keys do not move with the database; use encrypted export/import instead. A
|
||||
Windows portable build must move the complete `data` directory containing both
|
||||
`dbx.db` and `.dbx/secret.key`.
|
||||
|
||||
For the complete design, migration state machine, implementation map,
|
||||
troubleshooting, and test plan, see
|
||||
|
||||
+59
-12
@@ -69,6 +69,17 @@
|
||||
## ❤️ 赞助商
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jumpserver.org/" target="_blank">
|
||||
<img src="docs/public/sponsors/jumpserver-card.png" alt="JumpServer" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
JumpServer 是具备 AI 能力的开源特权访问管理(PAM)平台,为 DevOps 和 IT 团队提供统一的工作空间,安全访问 SSH、RDP、Kubernetes、数据库、网站、RemoteApp、VirtualApp 等资源。
|
||||
<a href="https://www.jumpserver.org/" target="_blank">访问 JumpServer</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">
|
||||
@@ -80,17 +91,6 @@
|
||||
<a href="https://www.rainyun.com/MTE5Mjc4Ng==_" target="_blank">访问雨云</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.jalapeno-cloud.ai/DBX" target="_blank">
|
||||
@@ -124,6 +124,17 @@
|
||||
<a href="https://api.hualong.online/register?promo=DBX%26HUALONG" target="_blank">访问 HuaLongAI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">
|
||||
<img src="docs/public/sponsors/hiapi-card.png" alt="HiAPI" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
HiAPI 是面向开发者的图片、视频、音频及文本模型 API 平台——GPT Image 2.5(Flare 与 Sunburst)、OpenAI 兼容文本接口,并可通过 Remote MCP/Agent Skills 接入 Claude Code、Cursor 等编程工具。按量付费、无最低消费;新用户注册赠送 200 Credits,充值送 10% 积分。
|
||||
<a href="https://www.hiapi.ai/invite/dbx?utm_source=dbx&utm_medium=affiliate&utm_campaign=backlink&utm_content=readme-zh" target="_blank">访问 HiAPI</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">
|
||||
@@ -135,6 +146,40 @@
|
||||
<a href="https://www.ucloud.cn/site/active/kuaijiesale.html?ytag=geo_waituo_github_dbx" target="_blank">访问 UCloud 优刻得</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">
|
||||
<img src="docs/public/sponsors/packyapi-card.png" alt="PackyCode" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
PackyCode 是一家稳定、高效的 API 中转服务商,一句话接入主流大模型。统一域名、统一密钥、智能容灾切换,97% 可用性;人民币 1:1 充值,无汇率无手续费坑,新用户首充立享折扣 + $1 免费体验额度,多分组折扣低至 2 折起,并提供专属 Codex/Claude Code 高速通道。
|
||||
<a href="https://www.packyapi.ai/register?aff=wyDA" target="_blank">访问 PackyCode</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://mate.vip/ee/" target="_blank">
|
||||
<img src="docs/public/sponsors/taiyi-card.png" alt="太一企业版" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
太一智能体企业版是面向央国企的私有化智能体平台,采用“1+N+N”架构:1 个平台、N 个终端、N 个场景。<strong>可信</strong>:Java 微服务架构,私有化部署,全程留痕可审计。<strong>可控</strong>:关键操作人工审批,资料按密级管理。<strong>可达</strong>:按单位模板直接生成公文、台账和汇报材料。<strong>可复制</strong>:场景方案包化交付,便于推广和二次开发。欢迎预约演示,或在 GitHub 搜索 MateClaw。
|
||||
<a href="https://mate.vip/ee/" target="_blank">访问太一企业版</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">
|
||||
<img src="docs/public/sponsors/trustasia-card.png" alt="TrustAsia" width="175" />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
由 TrustAsia 提供代码签名云签服务,实现 CICD 自动化构建可信软件。
|
||||
<a href="https://www.trustasia.com/ssl/trustasia/code-signing" target="_blank">访问 TrustAsia</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="middle" width="200">
|
||||
<a href="https://www.atlascloud.ai/?ref=6YYXWA" target="_blank">
|
||||
@@ -428,13 +473,15 @@ volumes:
|
||||
|
||||
连接、插件、AI 和 Tunnel 凭据写入 `dbx.db` 前会加密。桌面端使用本机凭据存储(macOS Keychain、Windows Credential Manager 或 Linux Secret Service)。Web/Docker 与直接运行 `dbx-web` 默认使用同一套数据目录托管密钥:`${DBX_DATA_DIR}/.dbx/secret.key`。只有在开始迁移或第一次写入敏感字段时才创建密钥;普通 Docker 部署只需持久化 `/app/data`,并且必须将 `.dbx/secret.key` 与 `dbx.db` 一起备份。该密钥不能防护整个数据卷被复制或泄露。
|
||||
|
||||
Windows 便携版例外:为保证 U 盘可跨电脑使用,本地存储密钥保存在便携数据目录的 `data/.dbx/secret.key`,必须与整个 `data` 目录一起移动和备份。已经由旧版使用 Windows Credential Manager 加密过的便携数据,需要先在原电脑启动一次新版;DBX 会验证旧密钥并将其复制到 U 盘。若原电脑或原密钥已经丢失,现有密文无法自动恢复,只能找回原密钥或恢复迁移前备份。密钥与数据库同行意味着复制或丢失整个 U 盘的人也能解密其中凭据,请妥善保护 U 盘。
|
||||
|
||||
生产环境可以使用 Docker/Kubernetes Secret 覆盖托管策略:设置 `DBX_SECRET_KEY_FILE`,或由密钥管理系统提供 `DBX_SECRET_KEY`。显式密钥优先,已有密文使用期间不能轮换。密钥不可用时,业务 API 保持阻塞,浏览器显示数据安全升级页面。直接运行二进制时设置 `DBX_DATA_DIR=/var/lib/dbx`,即可使用 `/var/lib/dbx/.dbx/secret.key`。
|
||||
|
||||
升级包含历史明文凭据的版本时,桌面端和 Web 会在进入主界面前显示 **数据安全升级向导**。点击 **开始迁移** 后,软件会创建权限受限的备份,迁移旧数据库和 JSON 凭据,并验证密文可读取。失败时保留原始数据和备份,根据向导提示修复后点击 **重试**。成功页面会显示备份路径。确认连接可用后,可点击 **删除迁移备份**,二次确认后删除迁移备份目录和本次迁移生成的旧 JSON `.bak` 文件;其他备份不会删除。没有历史数据的新用户检查后直接进入主界面。
|
||||
|
||||
本机 CLI 和独立 MCP 可以复用同一设备已有的平台凭据存储,也可以读取显式配置的 `DBX_SECRET_KEY_FILE` 或 `DBX_SECRET_KEY`。它们不会在启动检查时创建密钥,也不会自动迁移历史数据。遇到 `DATA_MIGRATION_REQUIRED` 时,请先使用桌面端或 Web 打开同一数据目录,完成升级向导。没有平台凭据存储的无界面主机应配置持久化密钥。
|
||||
|
||||
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。直接复制 `dbx.db` 不能作为跨平台同步方式,因为本机平台密钥不会随数据库移动。请使用加密导出/导入,让目标设备使用自己的本地密钥保存凭据。
|
||||
跨设备导出使用独立的同步口令,导出包不包含本地存储密钥。安装版或只复制 `dbx.db` 时,本机平台密钥不会随数据库移动,不能作为跨平台同步方式;请使用加密导出/导入。Windows 便携版则必须移动包含 `dbx.db` 和 `.dbx/secret.key` 的完整 `data` 目录。
|
||||
|
||||
完整的设计、迁移状态、实现模块、排障和测试说明请参阅:[DBX 数据安全升级与迁移](docs/data-security-migration.zh-CN.md)。
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
**/libs/*.jar
|
||||
!drivers/cache/libs/CacheDB.jar
|
||||
!drivers/gbase8c/libs/gbase8c-jdbc-3.0.0B02.jar
|
||||
!drivers/oscar/libs/oscarJDBC8.jar
|
||||
!drivers/sundb/libs/sundb8-1.0.2.jar
|
||||
!drivers/transwarp/libs/inceptor-sdk-transwarp-6.1.0-SNAPSHOT.jar
|
||||
|
||||
+10
-1
@@ -25,6 +25,7 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| exasol | Exasol | Exasol JDBC |
|
||||
| oceanbase-oracle | OceanBase Oracle Mode | OceanBase JDBC |
|
||||
| gbase8a | GBase 8a | External GBase 8a JDBC |
|
||||
| gbase8c | GBase 8c | Bundled GBase 8c JDBC |
|
||||
| gbase8s | GBase 8s | External GBase 8s JDBC |
|
||||
| oracle | Oracle 10g+ | go-ora native agent |
|
||||
| oracle-oci | Oracle OCI (thick driver) | godror native agent (Windows x64, CGO; shares `drivers/oracle-go`, built with `-tags oci`) |
|
||||
@@ -52,11 +53,19 @@ Each agent runs as a standalone process and communicates with DBX via stdin/stdo
|
||||
| etcd2 | etcd 2.0–2.3, 3.0–3.5 with --enable-v2 (v2 API) | etcd v2 HTTP/JSON native Go agent |
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper native agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go native agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go native agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go (Remoting) and official Go v5 SDK (Proxy gRPC) native agent |
|
||||
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
|
||||
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
|
||||
|
||||
|
||||
## RocketMQ Connection Modes
|
||||
|
||||
The same `rocketmq` Agent supports NameServer/Remoting and the official RocketMQ 5.x Proxy gRPC protocol. Proxy mode requires the handshake feature `rocketmq_proxy_grpc`; configurations without a mode continue to use Remoting.
|
||||
|
||||
Proxy messaging uses the official Go v5 SDK's RPC, signing and protocol types with per-connection TLS and dialers. SDK v5.1.4 does not forward the high-level Producer's connection options, so this implementation does not replace global TLS switches or transport constructors. It registers Producer settings through Telemetry before checking Heartbeat, keeps the stream open, and sends ordinary messages with keys, tags and user properties. It does not map consumption to a non-mutating peek. Optional NameServer administration reuses Remoting and reports its own degraded state.
|
||||
|
||||
Run `go test ./...` and `go test -race ./...` in the RocketMQ driver directory. The official Proxy integration test is opt-in: prepare a NORMAL topic, set `ROCKETMQ_PROXY_INTEGRATION=1`, `ROCKETMQ_PROXY_ENDPOINT`, `ROCKETMQ_PROXY_TOPIC` and `ROCKETMQ_PROXY_TLS`, then run the test. It publishes a test message. The native Agent does not require Java; Java is only used by the local official-service fixture.
|
||||
|
||||
## Multi-JRE Support
|
||||
|
||||
Most Java agents target JRE 21. Native agents, such as `cassandra`, `duckdb`, `hive`, `iotdb`, `oracle`, `kingbase`, `tdengine`, `xugu`, `rabbitmq`, `rocketmq`, `zookeeper`, `etcd`, and `etcd2`, do not require a JRE. DBX downloads and manages the JRE 21 installation automatically for Java agents.
|
||||
|
||||
+10
-1
@@ -25,6 +25,7 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
| exasol | Exasol | Exasol JDBC |
|
||||
| oceanbase-oracle | OceanBase Oracle 模式 | OceanBase JDBC |
|
||||
| gbase8a | GBase 8a | 外部 GBase 8a JDBC |
|
||||
| gbase8c | GBase 8c | 内置 GBase 8c JDBC |
|
||||
| gbase8s | GBase 8s | 外部 GBase 8s JDBC |
|
||||
| oracle | Oracle 10g+ | go-ora 原生 agent |
|
||||
| h2 | H2 | H2 JDBC |
|
||||
@@ -50,11 +51,19 @@ DBX 的 Agent 驱动 —— 通过 JDBC 和原生数据库驱动支持各种数
|
||||
| etcd | etcd | jetcd |
|
||||
| zookeeper | Apache ZooKeeper | go-zookeeper 原生 Agent |
|
||||
| rabbitmq | RabbitMQ | amqp091-go 原生 agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go 原生 agent |
|
||||
| rocketmq | Apache RocketMQ | rocketmq-admin-go(Remoting)及官方 Go v5 SDK(Proxy gRPC)原生 agent |
|
||||
| iris | InterSystems IRIS | InterSystems IRIS JDBC |
|
||||
| cache | InterSystems Caché | InterSystems Caché JDBC (CacheDB) |
|
||||
|
||||
|
||||
## RocketMQ 接入模式
|
||||
|
||||
同一 `rocketmq` Agent 支持原有 NameServer/Remoting 接入与 RocketMQ 5.x 官方 Proxy gRPC 接入。Proxy 模式通过握手能力 `rocketmq_proxy_grpc` 协商;未指定模式的旧配置继续使用 Remoting,无需迁移驱动标识。
|
||||
|
||||
Proxy 消息使用官方 Go v5 SDK 的 RPC、签名和协议类型,并为每个连接独立配置 TLS 与拨号器;SDK v5.1.4 高层 Producer 未传递连接选项,因此此处不使用全局 TLS 开关或全局连接构造器替换。消息支持普通发送、Key、Tag 与用户属性,不将 gRPC 消费映射为无副作用预览。可选 NameServer 管理通道复用现有 Remoting 实现,失败时独立标记 degraded。
|
||||
|
||||
开发验证可运行 `go test ./...` 与 `go test -race ./...`。官方 Proxy 集成用例默认关闭,需要预先准备普通 Topic,并显式设置 `ROCKETMQ_PROXY_INTEGRATION=1`、`ROCKETMQ_PROXY_ENDPOINT`、`ROCKETMQ_PROXY_TOPIC` 及 `ROCKETMQ_PROXY_TLS`;集成用例会向该 Topic 发送测试消息。原生 Agent 不需要 JRE,Java 仅用于本地官方服务集成 fixture。
|
||||
|
||||
## 多 JRE 支持
|
||||
|
||||
多数 Java agent 以 JRE 21 为目标。原生 agent(如 `cassandra`、`duckdb`、`hive`、`iotdb`、`oracle`、`kingbase`、`tdengine`、`xugu`、`rabbitmq`、`rocketmq` 和 `zookeeper`)不需要 JRE。对 Java agent,DBX 会自动下载并管理 JRE 21 安装。
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ def infrastructureProjects = ['common', 'test-support'] as Set
|
||||
def legacyStandaloneProjects = ['mongodb', 'kafka'] as Set
|
||||
def pooledJdbcProjects = [
|
||||
'access', 'bigquery', 'dameng', 'databend', 'databricks', 'db2', 'exasol',
|
||||
'firebird', 'gbase8a', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
|
||||
'firebird', 'gbase8a', 'gbase8c', 'gbase8s', 'goldendb', 'h2', 'h2-legacy', 'highgo',
|
||||
'ignite', 'ignite3', 'informix', 'iris', 'cache', 'kylin', 'oceanbase-oracle', 'oscar', 'saphana',
|
||||
'snowflake', 'spanner', 'spark', 'sqlserver-legacy', 'sundb', 'teradata', 'trino', 'uxdb',
|
||||
'vertica', 'yashandb', 'transwarp'
|
||||
@@ -61,7 +61,7 @@ configure(jdbcAgentProjects) {
|
||||
|
||||
configure(agentProjects.findAll { pooledJdbcProjects.contains(it.name) }) {
|
||||
dependencies {
|
||||
implementation 'com.zaxxer:HikariCP:7.0.2'
|
||||
runtimeOnly 'org.slf4j:slf4j-nop:2.0.17'
|
||||
implementation 'com.zaxxer:HikariCP:7.1.0'
|
||||
runtimeOnly 'org.slf4j:slf4j-nop:2.0.20'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.12.1'
|
||||
compileOnly 'com.zaxxer:HikariCP:7.0.2'
|
||||
testImplementation 'com.zaxxer:HikariCP:7.0.2'
|
||||
implementation 'com.google.code.gson:gson:2.14.0'
|
||||
compileOnly 'com.zaxxer:HikariCP:7.1.0'
|
||||
testImplementation 'com.zaxxer:HikariCP:7.1.0'
|
||||
testImplementation 'com.h2database:h2:2.3.232'
|
||||
testRuntimeOnly 'org.slf4j:slf4j-nop:2.0.17'
|
||||
testRuntimeOnly 'org.slf4j:slf4j-nop:2.0.20'
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
options.getFetchSize(),
|
||||
options.getTimeoutSecs(),
|
||||
resultValueReader(),
|
||||
JdbcExecutor.StatementMessageReader.NONE,
|
||||
statementMessageReader(),
|
||||
advancePastUpdateCounts()
|
||||
);
|
||||
}
|
||||
@@ -221,6 +221,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
this::resetSchemaSQL,
|
||||
options,
|
||||
resultValueReader(),
|
||||
statementMessageReader(),
|
||||
advancePastUpdateCounts()
|
||||
);
|
||||
}
|
||||
@@ -421,7 +422,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
releasePooledConnection(true);
|
||||
return;
|
||||
}
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (sessionAffinity || executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -438,7 +439,7 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
if (sessionAffinity) {
|
||||
return;
|
||||
}
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements()) {
|
||||
if (executor.hasOpenSessions() || executor.hasActiveStatements() || hasRetainedResultResources()) {
|
||||
return;
|
||||
}
|
||||
releasePooledConnection(!preparePooledConnectionForReturn());
|
||||
@@ -602,6 +603,10 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
protected void beforeQueryExecution(Connection connection, int timeoutSecs) throws Exception {
|
||||
}
|
||||
|
||||
protected JdbcExecutor.StatementMessageReader statementMessageReader() {
|
||||
return JdbcExecutor.StatementMessageReader.NONE;
|
||||
}
|
||||
|
||||
/** Called for each session RPC before its SQL can replace the connection's last trace ID. */
|
||||
protected void beforeAgentMethod(String method, String querySessionId) {
|
||||
}
|
||||
@@ -713,7 +718,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
);
|
||||
}
|
||||
|
||||
protected boolean hasRetainedResultResources() { return false; }
|
||||
|
||||
protected void releaseRetainedResultResources() { }
|
||||
|
||||
private void closeCurrentConnection() throws Exception {
|
||||
releaseRetainedResultResources();
|
||||
if (pooledLease != null) {
|
||||
boolean evict = pooledConnectionPoisoned || sessionAffinity || !preparePooledConnectionForReturn();
|
||||
releasePooledConnection(evict);
|
||||
@@ -732,7 +742,17 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent {
|
||||
return JdbcSchemaSwitcher.resetBeforeReturn(connection);
|
||||
}
|
||||
|
||||
protected final synchronized void discardConnection(Connection failedConnection) throws java.sql.SQLException {
|
||||
if (pooledLease != null && connection == failedConnection) {
|
||||
// Evict the checked-out lease before closing its proxy; close alone returns it to the pool.
|
||||
releasePooledConnection(true);
|
||||
} else {
|
||||
failedConnection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void releasePooledConnection(boolean evict) {
|
||||
releaseRetainedResultResources();
|
||||
JdbcConnectionPoolRegistry.Lease lease = pooledLease;
|
||||
pooledLease = null;
|
||||
connection = null;
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.dbx.agent;
|
||||
|
||||
final class AgentExecutionContext {
|
||||
private static final ThreadLocal<JdbcExecutor> JDBC_EXECUTOR = new ThreadLocal<>();
|
||||
private record StatementExecution(int timeoutSecs, JdbcExecutor.NativeOperation operation, AbstractJdbcAgent owner) { }
|
||||
private static final ThreadLocal<StatementExecution> STATEMENT_EXECUTION = new ThreadLocal<>();
|
||||
|
||||
private AgentExecutionContext() {
|
||||
}
|
||||
@@ -11,6 +13,35 @@ final class AgentExecutionContext {
|
||||
return executor == null ? JdbcExecutor.INSTANCE : executor;
|
||||
}
|
||||
|
||||
static int statementTimeoutSecs() {
|
||||
StatementExecution execution = STATEMENT_EXECUTION.get();
|
||||
return execution == null ? 0 : execution.timeoutSecs();
|
||||
}
|
||||
|
||||
static JdbcExecutor.NativeOperation statementOperation() {
|
||||
StatementExecution execution = STATEMENT_EXECUTION.get();
|
||||
return execution == null ? null : execution.operation();
|
||||
}
|
||||
|
||||
static void discardConnection(java.sql.Connection connection) throws java.sql.SQLException {
|
||||
StatementExecution execution = STATEMENT_EXECUTION.get();
|
||||
if (execution != null && execution.owner() != null) execution.owner().discardConnection(connection);
|
||||
else connection.close();
|
||||
}
|
||||
|
||||
static <T> T withStatementTimeout(int timeoutSecs, JdbcExecutor.NativeOperation operation, AbstractJdbcAgent owner,
|
||||
DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
|
||||
if (timeoutSecs < 0) throw new IllegalArgumentException("Query timeout cannot be negative");
|
||||
StatementExecution previous = STATEMENT_EXECUTION.get();
|
||||
STATEMENT_EXECUTION.set(new StatementExecution(timeoutSecs, operation, owner));
|
||||
try {
|
||||
return supplier.get();
|
||||
} finally {
|
||||
if (previous == null) STATEMENT_EXECUTION.remove();
|
||||
else STATEMENT_EXECUTION.set(previous);
|
||||
}
|
||||
}
|
||||
|
||||
static <T> T withJdbcExecutor(JdbcExecutor executor, DatabaseAgent.ThrowingSupplier<T> supplier) throws Exception {
|
||||
JdbcExecutor previous = JDBC_EXECUTOR.get();
|
||||
JDBC_EXECUTOR.set(executor);
|
||||
|
||||
@@ -38,6 +38,8 @@ public final class AgentProtocol {
|
||||
public static final String METHOD_EXECUTE_QUERY_PAGE = "execute_query_page";
|
||||
public static final String METHOD_FETCH_QUERY_PAGE = "fetch_query_page";
|
||||
public static final String METHOD_CLOSE_QUERY_SESSION = "close_query_session";
|
||||
public static final String METHOD_READ_LARGE_VALUE_CHUNK = "read_large_value_chunk";
|
||||
public static final String METHOD_RELEASE_LARGE_VALUE = "release_large_value";
|
||||
public static final String METHOD_START_TABLE_READ = "start_table_read";
|
||||
public static final String METHOD_FETCH_TABLE_READ_PAGE = "fetch_table_read_page";
|
||||
public static final String METHOD_CLOSE_TABLE_READ_SESSION = "close_table_read_session";
|
||||
@@ -141,6 +143,7 @@ public final class AgentProtocol {
|
||||
public static final String CAPABILITY_MONGO_FIND_CURSOR = "mongo_find_cursor";
|
||||
public static final String CAPABILITY_MULTI_SESSION = "multi_session";
|
||||
public static final String CAPABILITY_STRUCTURED_ERROR_V1 = "structured_error_v1";
|
||||
public static final String CAPABILITY_BLOB_BIND_STATEMENTS_V1 = "blob_bind_statements_v1";
|
||||
|
||||
public static final List<String> CAPABILITIES = Collections.unmodifiableList(Arrays.asList(
|
||||
CAPABILITY_CONNECT,
|
||||
@@ -215,6 +218,8 @@ public final class AgentProtocol {
|
||||
METHOD_EXECUTE_QUERY_PAGE,
|
||||
METHOD_FETCH_QUERY_PAGE,
|
||||
METHOD_CLOSE_QUERY_SESSION,
|
||||
METHOD_READ_LARGE_VALUE_CHUNK,
|
||||
METHOD_RELEASE_LARGE_VALUE,
|
||||
METHOD_START_TABLE_READ,
|
||||
METHOD_FETCH_TABLE_READ_PAGE,
|
||||
METHOD_CLOSE_TABLE_READ_SESSION,
|
||||
@@ -267,6 +272,7 @@ public final class AgentProtocol {
|
||||
|
||||
List<String> jdbcAllCapabilities = new java.util.ArrayList<>(MULTI_SESSION_ALL_CAPABILITIES);
|
||||
jdbcAllCapabilities.add(CAPABILITY_STRUCTURED_ERROR_V1);
|
||||
jdbcAllCapabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
MULTI_SESSION_JDBC_ALL_CAPABILITIES = Collections.unmodifiableList(jdbcAllCapabilities);
|
||||
|
||||
List<String> methods = new java.util.ArrayList<>(COMMON_METHODS);
|
||||
@@ -383,6 +389,13 @@ public final class AgentProtocol {
|
||||
);
|
||||
}
|
||||
|
||||
public static HandshakeResult multiSessionJdbcHandshakeResult(boolean blobBindStatements) {
|
||||
if (!blobBindStatements) return multiSessionJdbcHandshakeResult();
|
||||
java.util.ArrayList<String> capabilities = new java.util.ArrayList<>(MULTI_SESSION_JDBC_CAPABILITIES);
|
||||
capabilities.add(CAPABILITY_BLOB_BIND_STATEMENTS_V1);
|
||||
return new HandshakeResult(MULTI_SESSION_PROTOCOL_VERSION, MULTI_SESSION_PROTOCOL_VERSION, capabilities);
|
||||
}
|
||||
|
||||
public static final class HandshakeResult {
|
||||
private final int protocolVersion;
|
||||
private final int agentProtocolVersion;
|
||||
|
||||
@@ -57,6 +57,11 @@ final class AgentRpcError extends RuntimeException {
|
||||
);
|
||||
}
|
||||
|
||||
static AgentRpcError rollbackConfirmedSql(SQLException error) {
|
||||
return new AgentRpcError(message(error), "sql", false, "keep", "execute", "unknown",
|
||||
safeSqlState(error.getSQLState()), error.getErrorCode(), safeClassName(error), error);
|
||||
}
|
||||
|
||||
static AgentRpcError backpressure(String stage, Throwable cause) {
|
||||
return new AgentRpcError(
|
||||
"Agent request capacity is temporarily exhausted",
|
||||
|
||||
@@ -48,7 +48,14 @@ public abstract class BaseDatabaseAgent implements DatabaseAgent {
|
||||
// Table comment is optional; DDL generation should still succeed without it.
|
||||
}
|
||||
|
||||
return DdlBuilder.buildTableDdl(schema, table, getColumns(schema, table), indexes, foreignKeys, Collections.emptyList(), false, false, tableComment);
|
||||
return DdlBuilder.buildTableDdl(
|
||||
schema, table, getColumns(schema, table), indexes, foreignKeys,
|
||||
Collections.emptyList(), false, includeColumnCommentsInTableDdl(), tableComment
|
||||
);
|
||||
}
|
||||
|
||||
protected boolean includeColumnCommentsInTableDdl() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -30,25 +30,35 @@ public final class BatchExecutor {
|
||||
Supplier<String> resetSchemaSql
|
||||
) {
|
||||
return unchecked(() -> {
|
||||
long start = System.currentTimeMillis();
|
||||
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
Long batchAffected = tryExecuteBatch(conn, statements);
|
||||
long totalAffected = batchAffected == null ? executeIndividually(conn, statements) : batchAffected;
|
||||
return new QueryResult(
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
totalAffected,
|
||||
System.currentTimeMillis() - start,
|
||||
false
|
||||
);
|
||||
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
|
||||
long start = System.currentTimeMillis();
|
||||
operation.checkCancelled();
|
||||
applySchema(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
operation.checkCancelled();
|
||||
Long batchAffected = tryExecuteBatch(conn, statements, operation);
|
||||
long totalAffected = batchAffected == null ? executeIndividually(conn, statements, operation) : batchAffected;
|
||||
operation.checkCancelled();
|
||||
return new QueryResult(
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
totalAffected,
|
||||
System.currentTimeMillis() - start,
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static Long tryExecuteBatch(Connection conn, List<String> statements) throws Exception {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
private static Long tryExecuteBatch(Connection conn, List<String> statements, JdbcExecutor.NativeOperation operation) throws Exception {
|
||||
try (var tracked = JdbcExecutor.current().trackStatement(conn.createStatement())) {
|
||||
Statement stmt = tracked.statement();
|
||||
if (AgentExecutionContext.statementTimeoutSecs() > 0) {
|
||||
stmt.setQueryTimeout(AgentExecutionContext.statementTimeoutSecs());
|
||||
}
|
||||
try {
|
||||
int statementCount = 0;
|
||||
for (String statement : statements) {
|
||||
operation.checkCancelled();
|
||||
String trimmed = JdbcExecutor.trimSql(statement);
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
@@ -56,7 +66,8 @@ public final class BatchExecutor {
|
||||
stmt.addBatch(trimmed);
|
||||
statementCount++;
|
||||
}
|
||||
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt));
|
||||
operation.checkCancelled();
|
||||
return statementCount == 0 ? 0L : affectedRows(executeBatch(stmt, operation));
|
||||
} catch (BatchUpdateException e) {
|
||||
long[] counts = e.getLargeUpdateCounts();
|
||||
int failedIndex = counts == null ? 1 : counts.length + 1;
|
||||
@@ -67,11 +78,16 @@ public final class BatchExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private static long executeIndividually(Connection conn, List<String> statements) throws Exception {
|
||||
private static long executeIndividually(Connection conn, List<String> statements, JdbcExecutor.NativeOperation operation) throws Exception {
|
||||
long totalAffected = 0;
|
||||
int statementIndex = 0;
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
try (var tracked = JdbcExecutor.current().trackStatement(conn.createStatement())) {
|
||||
Statement stmt = tracked.statement();
|
||||
if (AgentExecutionContext.statementTimeoutSecs() > 0) {
|
||||
stmt.setQueryTimeout(AgentExecutionContext.statementTimeoutSecs());
|
||||
}
|
||||
for (String statement : statements) {
|
||||
operation.checkCancelled();
|
||||
String trimmed = JdbcExecutor.trimSql(statement);
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
@@ -107,10 +123,11 @@ public final class BatchExecutor {
|
||||
return total;
|
||||
}
|
||||
|
||||
private static long[] executeBatch(Statement stmt) throws Exception {
|
||||
private static long[] executeBatch(Statement stmt, JdbcExecutor.NativeOperation operation) throws Exception {
|
||||
try {
|
||||
return stmt.executeLargeBatch();
|
||||
} catch (SQLFeatureNotSupportedException | UnsupportedOperationException | AbstractMethodError e) {
|
||||
operation.checkCancelled();
|
||||
int[] counts = stmt.executeBatch();
|
||||
long[] largeCounts = new long[counts.length];
|
||||
for (int i = 0; i < counts.length; i++) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.Statement;
|
||||
import java.sql.Savepoint;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.SQLTransientConnectionException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class BlobBoundExecutor {
|
||||
private BlobBoundExecutor() { }
|
||||
|
||||
@FunctionalInterface
|
||||
public interface PreparedStatementConfigurer {
|
||||
void configure(PreparedStatement statement) throws SQLException;
|
||||
default void discardConnection(Connection connection) throws SQLException { connection.close(); }
|
||||
default boolean isRollbackConfirmedBusinessError(SQLException error) { return false; }
|
||||
default boolean isUnsupportedSavepointRelease(SQLException error) { return false; }
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction) {
|
||||
return execute(conn, previews, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs,
|
||||
transaction, statement -> { });
|
||||
}
|
||||
|
||||
public static QueryResult execute(Connection conn, List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql,
|
||||
int timeoutSecs, boolean transaction, PreparedStatementConfigurer configurer) {
|
||||
BlobBoundStatement.validate(previews, statements);
|
||||
if (timeoutSecs < 0) throw new IllegalArgumentException("BLOB binding timeout must be non-negative");
|
||||
try (JdbcExecutor.NativeOperation operation = JdbcExecutor.current().beginNativeOperation()) {
|
||||
operation.checkCancelled();
|
||||
boolean autoCommit = conn.getAutoCommit();
|
||||
if (transaction && !autoCommit) {
|
||||
throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open");
|
||||
}
|
||||
// Typed grid saves are atomic even when execute_batch omits useTransaction.
|
||||
// Existing manual transactions keep their batch-only savepoint boundary.
|
||||
boolean oneShot = transaction || autoCommit;
|
||||
if (oneShot && !conn.getMetaData().supportsTransactions()) {
|
||||
throw new UnsupportedOperationException("Transactions are not supported by this JDBC driver");
|
||||
}
|
||||
if (oneShot) conn.setAutoCommit(false);
|
||||
Savepoint savepoint = !oneShot ? conn.setSavepoint() : null;
|
||||
boolean finished = !oneShot;
|
||||
boolean discarded = false;
|
||||
boolean commitAttempted = false;
|
||||
try {
|
||||
long start = System.currentTimeMillis();
|
||||
long affected = executeAll(conn, statements, schema, setSchemaSql, resetSchemaSql, timeoutSecs, operation, configurer);
|
||||
operation.checkCancelled();
|
||||
if (oneShot) { commitAttempted = true; conn.commit(); }
|
||||
if (savepoint != null) releaseSavepoint(conn, savepoint, configurer);
|
||||
finished = true;
|
||||
return new QueryResult(Collections.emptyList(), Collections.emptyList(), affected,
|
||||
System.currentTimeMillis() - start, false);
|
||||
} catch (Exception failure) {
|
||||
if (oneShot) {
|
||||
try { conn.rollback(); finished = true; }
|
||||
catch (Exception rollbackFailure) {
|
||||
discarded = true;
|
||||
throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer);
|
||||
}
|
||||
if (!commitAttempted && failure instanceof SQLException sqlFailure
|
||||
&& configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
if (savepoint != null) {
|
||||
try { conn.rollback(savepoint); }
|
||||
catch (Exception rollbackFailure) { throw rollbackUnconfirmed(conn, failure, rollbackFailure, configurer); }
|
||||
try { releaseSavepoint(conn, savepoint, configurer); }
|
||||
catch (Exception releaseFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB savepoint cleanup failed after batch rollback; discard this connection and treat transaction state as unknown", "08007", releaseFailure);
|
||||
unsafe.addSuppressed(failure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
if (failure instanceof SQLException sqlFailure && configurer.isRollbackConfirmedBusinessError(sqlFailure)) {
|
||||
throw AgentRpcError.rollbackConfirmedSql(sqlFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
} finally {
|
||||
if (oneShot) {
|
||||
if (finished) {
|
||||
try { conn.setAutoCommit(autoCommit); }
|
||||
catch (Exception resetFailure) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB transaction connection reset failed; committed writes may exist and operation outcome is unknown", "08007", resetFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
throw unsafe;
|
||||
}
|
||||
} else if (!discarded) configurer.discardConnection(conn); // Never enable auto-commit after an unconfirmed rollback.
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception failure) {
|
||||
throw new RuntimeException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static SQLTransientConnectionException rollbackUnconfirmed(Connection conn, Exception failure,
|
||||
Exception rollbackFailure, PreparedStatementConfigurer configurer) {
|
||||
SQLTransientConnectionException unsafe = new SQLTransientConnectionException(
|
||||
"BLOB binding rollback was not confirmed; discard this connection and treat the operation outcome as unknown", "08007", failure);
|
||||
unsafe.addSuppressed(rollbackFailure);
|
||||
try { configurer.discardConnection(conn); } catch (Exception closeFailure) { unsafe.addSuppressed(closeFailure); }
|
||||
return unsafe;
|
||||
}
|
||||
|
||||
private static void releaseSavepoint(Connection conn, Savepoint savepoint,
|
||||
PreparedStatementConfigurer configurer) throws SQLException {
|
||||
try { conn.releaseSavepoint(savepoint); }
|
||||
catch (SQLException error) {
|
||||
if (!configurer.isUnsupportedSavepointRelease(error)) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static long executeAll(Connection conn, List<BlobBoundStatement> statements, String schema,
|
||||
Function<String, String> setSchemaSql, Supplier<String> resetSchemaSql, int timeoutSecs,
|
||||
JdbcExecutor.NativeOperation operation, PreparedStatementConfigurer configurer) throws Exception {
|
||||
JdbcExecutor executor = JdbcExecutor.current();
|
||||
try (BoundResources resources = new BoundResources()) {
|
||||
operation.checkCancelled();
|
||||
JdbcSchemaSwitcher.apply(conn, schema, setSchemaSql, resetSchemaSql);
|
||||
for (BlobBoundStatement bound : statements) {
|
||||
operation.checkCancelled();
|
||||
String sql = JdbcExecutor.trimSql(bound.sql());
|
||||
Statement statement = bound.blobParameters().isEmpty() ? conn.createStatement()
|
||||
: isAnonymousBlock(sql) ? conn.prepareCall(sql) : conn.prepareStatement(sql);
|
||||
resources.statements.add(executor.trackStatement(statement));
|
||||
statement.setQueryTimeout(timeoutSecs);
|
||||
if (statement instanceof PreparedStatement prepared) {
|
||||
configurer.configure(prepared);
|
||||
int index = 1;
|
||||
for (String hex : bound.blobParameters()) {
|
||||
InputStream stream = new HexStream(hex);
|
||||
resources.streams.add(stream);
|
||||
prepared.setBlob(index++, stream, (long) hex.length() / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Prepare and bind the complete batch before the first write.
|
||||
long affected = 0;
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
operation.checkCancelled();
|
||||
Statement statement = resources.statements.get(i).statement();
|
||||
boolean result = statement instanceof PreparedStatement prepared ? prepared.execute()
|
||||
: statement.execute(JdbcExecutor.trimSql(statements.get(i).sql()));
|
||||
if (!result) affected += Math.max(0, statement.getUpdateCount());
|
||||
operation.checkCancelled();
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundResources implements AutoCloseable {
|
||||
final List<JdbcExecutor.TrackedStatement<Statement>> statements = new ArrayList<>();
|
||||
final List<InputStream> streams = new ArrayList<>();
|
||||
@Override public void close() throws Exception {
|
||||
Exception failure = null;
|
||||
List<AutoCloseable> resources = new ArrayList<>(statements);
|
||||
resources.addAll(streams);
|
||||
for (AutoCloseable resource : resources) {
|
||||
try { resource.close(); }
|
||||
catch (Exception error) {
|
||||
if (failure == null) failure = error;
|
||||
else failure.addSuppressed(error);
|
||||
}
|
||||
}
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnonymousBlock(String sql) {
|
||||
String normalized = sql.toUpperCase(Locale.ROOT);
|
||||
return normalized.startsWith("DECLARE") || normalized.startsWith("BEGIN");
|
||||
}
|
||||
|
||||
/** Decode already-validated hex without retaining a second full byte array. */
|
||||
private static final class HexStream extends InputStream {
|
||||
private final String hex;
|
||||
private int offset;
|
||||
private boolean closed;
|
||||
HexStream(String hex) { this.hex = hex; }
|
||||
@Override public int available() { return closed ? 0 : (hex.length() - offset) / 2; }
|
||||
@Override public int read() throws java.io.IOException {
|
||||
if (closed) throw new java.io.IOException("BLOB binding stream is closed");
|
||||
if (offset == hex.length()) return -1;
|
||||
int value = Character.digit(hex.charAt(offset), 16) * 16 + Character.digit(hex.charAt(offset + 1), 16);
|
||||
offset += 2;
|
||||
return value;
|
||||
}
|
||||
@Override public void close() { closed = true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Optional BLOB-only binding payload; preview text stays authoritative. */
|
||||
public final class BlobBoundStatement {
|
||||
private String previewSql;
|
||||
private String sql;
|
||||
private List<String> blobParameters;
|
||||
|
||||
public BlobBoundStatement(String previewSql, String sql, List<String> blobParameters) {
|
||||
this.previewSql = previewSql;
|
||||
this.sql = sql;
|
||||
this.blobParameters = blobParameters;
|
||||
}
|
||||
|
||||
public String sql() { return sql; }
|
||||
public List<String> blobParameters() { return blobParameters; }
|
||||
|
||||
public static void validate(List<String> statements, List<BlobBoundStatement> boundStatements) {
|
||||
if (statements == null || boundStatements == null || statements.size() != boundStatements.size()) {
|
||||
throw new IllegalArgumentException("BLOB bound statements must match the complete statement batch");
|
||||
}
|
||||
for (int i = 0; i < statements.size(); i++) {
|
||||
BlobBoundStatement bound = boundStatements.get(i);
|
||||
if (bound == null || statements.get(i) == null || !statements.get(i).equals(bound.previewSql)
|
||||
|| bound.sql == null || bound.sql.trim().isEmpty() || bound.blobParameters == null) {
|
||||
throw new IllegalArgumentException("BLOB binding preview mismatch at statement " + (i + 1));
|
||||
}
|
||||
if (bound.blobParameters.isEmpty() && !bound.sql.equals(bound.previewSql)) {
|
||||
throw new IllegalArgumentException("Unbound statement SQL must match its preview");
|
||||
}
|
||||
SqlScan scan = scanSql(bound.sql);
|
||||
String visible = scan.visible().stripLeading().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!visible.matches("(?s)^(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
&& !visible.matches("(?s)^(?:DECLARE\\b.*?)?BEGIN\\s+(?:UPDATE|INSERT|DELETE)\\b.*")
|
||||
|| java.util.regex.Pattern.compile("\\b(?:CREATE|ALTER|DROP|TRUNCATE|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|EXECUTE)\\b").matcher(visible).find()) {
|
||||
throw new IllegalArgumentException("BLOB binding only supports data-grid DML, without DDL or transaction control");
|
||||
}
|
||||
if (!bound.blobParameters.isEmpty() && scan.parameters() != bound.blobParameters.size()) {
|
||||
throw new IllegalArgumentException("BLOB parameter count does not match statement " + (i + 1));
|
||||
}
|
||||
for (String hex : bound.blobParameters) {
|
||||
if (hex == null || (hex.length() & 1) != 0) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain even-length hexadecimal bytes");
|
||||
}
|
||||
for (int j = 0; j < hex.length(); j++) {
|
||||
if (Character.digit(hex.charAt(j), 16) < 0 || hex.charAt(j) > 127) {
|
||||
throw new IllegalArgumentException("BLOB parameters must contain hexadecimal bytes only");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record SqlScan(int parameters, String visible) { }
|
||||
|
||||
private static SqlScan scanSql(String sql) {
|
||||
int count = 0;
|
||||
StringBuilder visible = new StringBuilder();
|
||||
for (int i = 0; i < sql.length(); i++) {
|
||||
char ch = sql.charAt(i);
|
||||
char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0';
|
||||
if (ch == '-' && next == '-') {
|
||||
visible.append(' ');
|
||||
while (i + 1 < sql.length() && sql.charAt(i + 1) != '\n') i++;
|
||||
} else if (ch == '/' && next == '*') {
|
||||
visible.append(' ');
|
||||
int end = sql.indexOf("*/", i + 2);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if ((ch == 'q' || ch == 'Q') && next == '\'' && i + 2 < sql.length()) {
|
||||
visible.append(' ');
|
||||
char open = sql.charAt(i + 2);
|
||||
char close = open == '[' ? ']' : open == '(' ? ')' : open == '{' ? '}' : open == '<' ? '>' : open;
|
||||
int end = sql.indexOf("" + close + '\'', i + 3);
|
||||
i = end < 0 ? sql.length() : end + 1;
|
||||
} else if (ch == '\'' || ch == '"') {
|
||||
visible.append(' ');
|
||||
char quote = ch;
|
||||
while (++i < sql.length()) {
|
||||
if (sql.charAt(i) == quote) {
|
||||
if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visible.append(ch);
|
||||
if (ch == '?') count++;
|
||||
}
|
||||
}
|
||||
return new SqlScan(count, visible.toString());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ public final class ColumnInfo {
|
||||
private Integer character_maximum_length;
|
||||
private String character_set;
|
||||
private String collation;
|
||||
private String resolved_schema;
|
||||
private String resolved_table;
|
||||
private String resolved_object_type;
|
||||
|
||||
public ColumnInfo() {
|
||||
this("", "", true, null, false);
|
||||
@@ -128,6 +131,18 @@ public final class ColumnInfo {
|
||||
return collation;
|
||||
}
|
||||
|
||||
public String getResolved_schema() {
|
||||
return resolved_schema;
|
||||
}
|
||||
|
||||
public String getResolved_table() {
|
||||
return resolved_table;
|
||||
}
|
||||
|
||||
public String getResolved_object_type() {
|
||||
return resolved_object_type;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
@@ -176,6 +191,18 @@ public final class ColumnInfo {
|
||||
this.collation = collation;
|
||||
}
|
||||
|
||||
public void setResolved_schema(String resolved_schema) {
|
||||
this.resolved_schema = resolved_schema;
|
||||
}
|
||||
|
||||
public void setResolved_table(String resolved_table) {
|
||||
this.resolved_table = resolved_table;
|
||||
}
|
||||
|
||||
public void setResolved_object_type(String resolved_object_type) {
|
||||
this.resolved_object_type = resolved_object_type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
if (this == other) return true;
|
||||
@@ -192,7 +219,10 @@ public final class ColumnInfo {
|
||||
&& Objects.equals(numeric_scale, that.numeric_scale)
|
||||
&& Objects.equals(character_maximum_length, that.character_maximum_length)
|
||||
&& Objects.equals(character_set, that.character_set)
|
||||
&& Objects.equals(collation, that.collation);
|
||||
&& Objects.equals(collation, that.collation)
|
||||
&& Objects.equals(resolved_schema, that.resolved_schema)
|
||||
&& Objects.equals(resolved_table, that.resolved_table)
|
||||
&& Objects.equals(resolved_object_type, that.resolved_object_type);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -209,7 +239,10 @@ public final class ColumnInfo {
|
||||
numeric_scale,
|
||||
character_maximum_length,
|
||||
character_set,
|
||||
collation
|
||||
collation,
|
||||
resolved_schema,
|
||||
resolved_table,
|
||||
resolved_object_type
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,6 +260,9 @@ public final class ColumnInfo {
|
||||
+ ", character_maximum_length=" + character_maximum_length
|
||||
+ ", character_set=" + character_set
|
||||
+ ", collation=" + collation
|
||||
+ ", resolved_schema=" + resolved_schema
|
||||
+ ", resolved_table=" + resolved_table
|
||||
+ ", resolved_object_type=" + resolved_object_type
|
||||
+ ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public final class CompletionAssistantCandidate {
|
||||
private final String parent_name;
|
||||
private final String comment;
|
||||
private final String data_type;
|
||||
private final String signature;
|
||||
private final String routine_id;
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
@@ -19,6 +21,21 @@ public final class CompletionAssistantCandidate {
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType
|
||||
) {
|
||||
this(name, kind, database, schema, parentSchema, parentName, comment, dataType, null, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantCandidate(
|
||||
String name,
|
||||
CompletionAssistantCandidateKind kind,
|
||||
String database,
|
||||
String schema,
|
||||
String parentSchema,
|
||||
String parentName,
|
||||
String comment,
|
||||
String dataType,
|
||||
String signature,
|
||||
String routineId
|
||||
) {
|
||||
this.name = name;
|
||||
this.kind = kind;
|
||||
@@ -28,6 +45,8 @@ public final class CompletionAssistantCandidate {
|
||||
this.parent_name = parentName;
|
||||
this.comment = comment;
|
||||
this.data_type = dataType;
|
||||
this.signature = signature;
|
||||
this.routine_id = routineId;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
@@ -38,4 +57,6 @@ public final class CompletionAssistantCandidate {
|
||||
public String getParent_name() { return parent_name; }
|
||||
public String getComment() { return comment; }
|
||||
public String getData_type() { return data_type; }
|
||||
public String getSignature() { return signature; }
|
||||
public String getRoutine_id() { return routine_id; }
|
||||
}
|
||||
|
||||
@@ -6,11 +6,17 @@ public final class CompletionAssistantResponse {
|
||||
private final List<CompletionAssistantCandidate> candidates;
|
||||
private final boolean incomplete;
|
||||
private final boolean fallback_used;
|
||||
private final Boolean routine_search_supported;
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed) {
|
||||
this(candidates, incomplete, fallbackUsed, null);
|
||||
}
|
||||
|
||||
public CompletionAssistantResponse(List<CompletionAssistantCandidate> candidates, boolean incomplete, boolean fallbackUsed, Boolean routineSearchSupported) {
|
||||
this.candidates = candidates;
|
||||
this.incomplete = incomplete;
|
||||
this.fallback_used = fallbackUsed;
|
||||
this.routine_search_supported = routineSearchSupported;
|
||||
}
|
||||
|
||||
public List<CompletionAssistantCandidate> getCandidates() { return candidates; }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.dbx.agent;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Shared wire shape; null state properties mean the database did not report them. */
|
||||
public record ConstraintInfo(
|
||||
String name, String constraint_type, String definition, List<String> columns,
|
||||
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid,
|
||||
OracleConstraintMetadata oracle_metadata
|
||||
) {
|
||||
public ConstraintInfo(
|
||||
String name, String constraint_type, String definition, List<String> columns,
|
||||
Boolean deferrable, Boolean initially_deferred, Boolean enabled, Boolean valid
|
||||
) {
|
||||
this(name, constraint_type, definition, columns, deferrable, initially_deferred, enabled, valid, null);
|
||||
}
|
||||
|
||||
public record OracleConstraintMetadata(
|
||||
boolean metadataComplete, String owner, String constraintType, String status, String validated,
|
||||
String deferrable, String deferred, String rely, String indexOwner, String indexName,
|
||||
String indexSharing, OracleConstraintIndexMetadata index
|
||||
) {}
|
||||
|
||||
public record OracleConstraintIndexMetadata(
|
||||
boolean metadataComplete, String owner, String tableOwner, String tableName, String indexType,
|
||||
String uniqueness, String status, boolean partitioned, String visibility, String secondary,
|
||||
List<String> columns, List<String> directions, List<Boolean> keyIsExpression, List<String> includedColumns
|
||||
) {}
|
||||
}
|
||||
@@ -14,6 +14,13 @@ public interface DatabaseAgent {
|
||||
/** Opt in to request-boundary timing, including pooled connection lifecycle. */
|
||||
default boolean supportsQueryTiming() { return false; }
|
||||
|
||||
default boolean supportsBlobBindStatements() { return false; }
|
||||
|
||||
default QueryResult executeBlobBoundStatements(List<String> previews, List<BlobBoundStatement> statements,
|
||||
String schema, int timeoutSecs, boolean transaction) {
|
||||
throw new UnsupportedOperationException("BLOB statement binding is not supported by this agent");
|
||||
}
|
||||
|
||||
/** A dedicated manual transaction must never migrate to a replacement connection. */
|
||||
default boolean permitsAutomaticReconnect() { return true; }
|
||||
|
||||
@@ -86,6 +93,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ColumnInfo> getColumns(String schema, String table);
|
||||
|
||||
default List<ColumnInfo> getColumnsInContext(String schema, String table, String currentSchema) {
|
||||
return getColumns(schema, table);
|
||||
}
|
||||
|
||||
default ObjectSource getObjectSource(String schema, String name, String objectType) {
|
||||
throw new UnsupportedOperationException("Object source is not supported");
|
||||
}
|
||||
@@ -156,6 +167,10 @@ public interface DatabaseAgent {
|
||||
|
||||
List<ForeignKeyInfo> listForeignKeys(String schema, String table);
|
||||
|
||||
default List<ConstraintInfo> listConstraints(String schema, String table) {
|
||||
throw new UnsupportedOperationException("Constraint metadata is not supported by this driver");
|
||||
}
|
||||
|
||||
List<TriggerInfo> listTriggers(String schema, String table);
|
||||
|
||||
default List<PartitionInfo> listPartitions(String schema, String table) {
|
||||
@@ -200,6 +215,14 @@ public interface DatabaseAgent {
|
||||
return AgentExecutionContext.jdbcExecutor().closeQuerySession(sessionId);
|
||||
}
|
||||
|
||||
default Object readLargeValueChunk(String valueRef, long offset, int limit) {
|
||||
throw new UnsupportedOperationException("LOB snapshot chunks are not supported by this agent");
|
||||
}
|
||||
|
||||
default boolean releaseLargeValue(String valueRef) { return false; }
|
||||
|
||||
default void invalidateLargeValues() { }
|
||||
|
||||
default QueryPageResult startTableRead(String sql, String schema, QueryPageOptions options) {
|
||||
Connection conn = getConnection();
|
||||
if (conn == null) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user