Compare commits
643
Commits
64edbc5835
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eafb757a9 | ||
|
|
576349d545 | ||
|
|
ae6f0b74db | ||
|
|
553f745f23 | ||
|
|
cc3ad0870a | ||
|
|
ee90a5e7a2 | ||
|
|
237656702f | ||
|
|
6d86db793b | ||
|
|
56f1230a10 | ||
|
|
d302602567 | ||
|
|
22698c1b5f | ||
|
|
de27053f74 | ||
|
|
5933d87647 | ||
|
|
72b18143f8 | ||
|
|
8886ce201f | ||
|
|
bee24e2b10 | ||
|
|
a7aac0df1d | ||
|
|
ec903b0d61 | ||
|
|
f34319852e | ||
|
|
2fa97c26fb | ||
|
|
5ab5fe8583 | ||
|
|
f32aaaa3b5 | ||
|
|
5347b313ea | ||
|
|
1be4600261 | ||
|
|
780ef0b1be | ||
|
|
a654cda058 | ||
|
|
ab1c2d7973 | ||
|
|
6a179aeb85 | ||
|
|
f20c8436aa | ||
|
|
db5d14184f | ||
|
|
10228e1c06 | ||
|
|
d8b2b8f1a7 | ||
|
|
9e1d5c5649 | ||
|
|
2429d9d2e8 | ||
|
|
16cd871fbd | ||
|
|
783ea88a01 | ||
|
|
036b78e31e | ||
|
|
08c3488d7c | ||
|
|
4f2fd9a8b1 | ||
|
|
4185ee1417 | ||
|
|
ae6a3ae00c | ||
|
|
2fde92ad25 | ||
|
|
ebd06b73c9 | ||
|
|
af9f1c5944 | ||
|
|
f83d2c2027 | ||
|
|
93781af7e9 | ||
|
|
314615ec2c | ||
|
|
52013791d4 | ||
|
|
2ac7519792 | ||
|
|
a32c68e24b | ||
|
|
7186718cfd | ||
|
|
964505654f | ||
|
|
008cd3fd74 | ||
|
|
11ffe0f9dd | ||
|
|
683649424b | ||
|
|
ac531dda05 | ||
|
|
36781ebef7 | ||
|
|
2941ec529c | ||
|
|
eddb68818e | ||
|
|
b98e8dfa1a | ||
|
|
86651a3d05 | ||
|
|
3f3f880147 | ||
|
|
6094069ef6 | ||
|
|
6913ac98f1 | ||
|
|
faf36b5bdf | ||
|
|
05b4f13e96 | ||
|
|
57b2023611 | ||
|
|
5debfcf178 | ||
|
|
27d75facd4 | ||
|
|
2b4c291f12 | ||
|
|
b0ae1605c3 | ||
|
|
c738e5b10d | ||
|
|
fe9faec741 | ||
|
|
7f2a5a3d3c | ||
|
|
946d6c2f81 | ||
|
|
64b83cd1fb | ||
|
|
23535e30bc | ||
|
|
351ef3899b | ||
|
|
1334b0b320 | ||
|
|
975674d7f5 | ||
|
|
8d1098f916 | ||
|
|
49aa3b2812 | ||
|
|
94004f05ac | ||
|
|
8d6d3cb014 | ||
|
|
f558b80cc8 | ||
|
|
223cde1b94 | ||
|
|
9b96085d8d | ||
|
|
ddb1dd941b | ||
|
|
d7c29afde5 | ||
|
|
cc4b95839d | ||
|
|
325e0bb44c | ||
|
|
cf130ed0fc | ||
|
|
cd83b7588f | ||
|
|
b3bc459423 | ||
|
|
e2ef8fbda0 | ||
|
|
5cf44d78e6 | ||
|
|
254ccf5821 | ||
|
|
a8177c2e73 | ||
|
|
e8f93e93d7 | ||
|
|
7acd55d2d2 | ||
|
|
b52fa2060f | ||
|
|
932b280a27 | ||
|
|
c35f713291 | ||
|
|
9340dc7009 | ||
|
|
8e7a69800a | ||
|
|
b14b1a9663 | ||
|
|
eac2b0f6f2 | ||
|
|
7274b76e81 | ||
|
|
5ada9e6cc0 | ||
|
|
9c599bf054 | ||
|
|
6cce87031b | ||
|
|
71e28aa5b4 | ||
|
|
0631e2e155 | ||
|
|
46709537c1 | ||
|
|
62ab4b4d95 | ||
|
|
ce0fb42bef | ||
|
|
92fc913cad | ||
|
|
412f0e0081 | ||
|
|
7cb024d4a5 | ||
|
|
1ed1539acf | ||
|
|
81ff3608ae | ||
|
|
e2e51290c4 | ||
|
|
a2e8bf17ec | ||
|
|
e8947438c9 | ||
|
|
bc16f6652b | ||
|
|
e7811cb5bb | ||
|
|
e5bf8c9647 | ||
|
|
b68805e88a | ||
|
|
ffa1ff95cc | ||
|
|
95f77bb648 | ||
|
|
08957aeb2a | ||
|
|
4d865666c7 | ||
|
|
d9cf26ddbf | ||
|
|
95ec891bd2 | ||
|
|
72ae320400 | ||
|
|
f04f61058f | ||
|
|
b71dfe9696 | ||
|
|
a82d35f8bd | ||
|
|
066eec8477 | ||
|
|
f29df56a99 | ||
|
|
8e9e4670c6 | ||
|
|
e4f1a52a87 | ||
|
|
a2b7327447 | ||
|
|
0a202970fd | ||
|
|
631620d264 | ||
|
|
7fb813446f | ||
|
|
f6b2adac4d | ||
|
|
719de6fb7a | ||
|
|
0ee89fe347 | ||
|
|
7a0de4a8e9 | ||
|
|
4e019f1a59 | ||
|
|
4bcdd350f6 | ||
|
|
e0c583db75 | ||
|
|
32f1f3486f | ||
|
|
32fc1719aa | ||
|
|
8ff1924047 | ||
|
|
84d921a52f | ||
|
|
44cf2a4ce8 | ||
|
|
22d4735170 | ||
|
|
87d6532cb3 | ||
|
|
d663114976 | ||
|
|
565363d5f4 | ||
|
|
7ba469f292 | ||
|
|
d57ca94ec5 | ||
|
|
9a2e585a9e | ||
|
|
add87b5708 | ||
|
|
81822da04d | ||
|
|
05ea99cd32 | ||
|
|
7c3699ce2d | ||
|
|
c3452ce866 | ||
|
|
97b3c6573b | ||
|
|
51e226e8ba | ||
|
|
09d8db5b9e | ||
|
|
e97b1d3642 | ||
|
|
b12212493c | ||
|
|
b7e0c537f3 | ||
|
|
2a3d30fb3a | ||
|
|
1ffa911dbc | ||
|
|
5ad764b359 | ||
|
|
0e791325f8 | ||
|
|
826d632173 | ||
|
|
9d86fc44e4 | ||
|
|
8db26c9a15 | ||
|
|
ae9ed8a908 | ||
|
|
f1449ff5c8 | ||
|
|
08bcc03f17 | ||
|
|
6e589e73ea | ||
|
|
8c171fb47b | ||
|
|
18e7d7b6e1 | ||
|
|
bd22135906 | ||
|
|
78fce0dbb6 | ||
|
|
fd2bc018ff | ||
|
|
daf9910e83 | ||
|
|
535da1bc5a | ||
|
|
dfbee45309 | ||
|
|
a4e014e62b | ||
|
|
eeadb1bbea | ||
|
|
c64809134f | ||
|
|
749ca04388 | ||
|
|
ea51b0196a | ||
|
|
cc63ab74e6 | ||
|
|
9438855fdd | ||
|
|
cfce823dd7 | ||
|
|
7231f27c1c | ||
|
|
e27b6ddefb | ||
|
|
cefebd7643 | ||
|
|
3d540f6fba | ||
|
|
2bb45c0f80 | ||
|
|
0b62d4e06a | ||
|
|
3550262613 | ||
|
|
59f2de9711 | ||
|
|
c0b1f562a9 | ||
|
|
560cfa8f47 | ||
|
|
3b4f222a7d | ||
|
|
a2aea5357d | ||
|
|
7d5c7df82f | ||
|
|
08ea214526 | ||
|
|
6b45212659 | ||
|
|
ccdff1df27 | ||
|
|
af938064cd | ||
|
|
2faf18a802 | ||
|
|
236c8b779f | ||
|
|
564fee9639 | ||
|
|
d56baa635e | ||
|
|
c54ac0d4de | ||
|
|
b7755f26e3 | ||
|
|
3cb05284b3 | ||
|
|
4d24c34548 | ||
|
|
4561d7ad12 | ||
|
|
9e29d00292 | ||
|
|
3d11e1f12b | ||
|
|
182fcbf598 | ||
|
|
ce61e424f6 | ||
|
|
96fe077292 | ||
|
|
f89949cea9 | ||
|
|
3b499cec2a | ||
|
|
eff4572a91 | ||
|
|
b3edd53185 | ||
|
|
25cf323d14 | ||
|
|
429faccd08 | ||
|
|
9ada4762c5 | ||
|
|
33d01ba65e | ||
|
|
3bce0a55fb | ||
|
|
afb4ba563c | ||
|
|
9235f3a23c | ||
|
|
d50328aea4 | ||
|
|
48cf3a334b | ||
|
|
974463163d | ||
|
|
cedf451a2b | ||
|
|
be592d6d4d | ||
|
|
0cbd1fc801 | ||
|
|
673cb77004 | ||
|
|
d6b1e4be3c | ||
|
|
28f0f84de8 | ||
|
|
76d1417c28 | ||
|
|
685e627984 | ||
|
|
08202f7eaa | ||
|
|
a863928661 | ||
|
|
7af73e1539 | ||
|
|
8f866ae753 | ||
|
|
ae0478bf54 | ||
|
|
cc5686b896 | ||
|
|
d4dc9aa854 | ||
|
|
3496fc3952 | ||
|
|
89582a0f2a | ||
|
|
831183308d | ||
|
|
a1de4ab8f9 | ||
|
|
3c41d2af5c | ||
|
|
d306c0a5ec | ||
|
|
85532059e2 | ||
|
|
54a75153a9 | ||
|
|
5dfc00319f | ||
|
|
f51dc95153 | ||
|
|
b15494deb9 | ||
|
|
55b3658d93 | ||
|
|
c7b462a034 | ||
|
|
2c47208b0d | ||
|
|
e956040b6b | ||
|
|
43a17a7e8e | ||
|
|
d042a9ca24 | ||
|
|
f061fee18d | ||
|
|
551ee7d70c | ||
|
|
ddaf380db2 | ||
|
|
8b551c565f | ||
|
|
66d37dd3cb | ||
|
|
44a7c62dc3 | ||
|
|
d5dc9f2708 | ||
|
|
3e4b721d60 | ||
|
|
1830360850 | ||
|
|
06c2069234 | ||
|
|
cc4741a4ce | ||
|
|
d95ea323d7 | ||
|
|
e4adccd82a | ||
|
|
89a7d4dbfc | ||
|
|
795f544047 | ||
|
|
b33844d74a | ||
|
|
22a1c4c2df | ||
|
|
84b841c727 | ||
|
|
09e83fd034 | ||
|
|
276a71bd1a | ||
|
|
0e701d578a | ||
|
|
ee8e9a0247 | ||
|
|
f5370a94d3 | ||
|
|
c91e3642de | ||
|
|
3599a9e12a | ||
|
|
f845864889 | ||
|
|
6670c72e65 | ||
|
|
1529b9ff6b | ||
|
|
4a95c65f8b | ||
|
|
16dcf65825 | ||
|
|
81fcdb09fd | ||
|
|
a9265efa82 | ||
|
|
30ded9b712 | ||
|
|
5512ba7710 | ||
|
|
a8fcf0e01c | ||
|
|
934688a71d | ||
|
|
1320a55a7e | ||
|
|
0bad26230b | ||
|
|
cf057d7829 | ||
|
|
87beb44394 | ||
|
|
0d41760ea3 | ||
|
|
b9e5cd0f57 | ||
|
|
db032ef93c | ||
|
|
1a1e679246 | ||
|
|
9fde4f3e76 | ||
|
|
6b5d2ad080 | ||
|
|
f5654963eb | ||
|
|
af7131abf1 | ||
|
|
027a3cb92c | ||
|
|
71ccbca10f | ||
|
|
6220304f8c | ||
|
|
6a37f7c8d5 | ||
|
|
fcb9944378 | ||
|
|
f75f2327f5 | ||
|
|
9f4dd4c39b | ||
|
|
6e212c0de7 | ||
|
|
d446a31442 | ||
|
|
b11160f236 | ||
|
|
d814a9ca81 | ||
|
|
10e64f6683 | ||
|
|
5513bdf2aa | ||
|
|
273eba7fb3 | ||
|
|
cd61272837 | ||
|
|
7fbb1bf34a | ||
|
|
c1d85b621a | ||
|
|
4243b60ca3 | ||
|
|
f5953549aa | ||
|
|
ab6bb593bc | ||
|
|
4cf75bcbc9 | ||
|
|
9ff861a1f3 | ||
|
|
8d5fa06351 | ||
|
|
7db8298cdd | ||
|
|
167257b8a3 | ||
|
|
23380d27fe | ||
|
|
bb525a8f94 | ||
|
|
d5b185ce38 | ||
|
|
b5ee6567aa | ||
|
|
103364aa8d | ||
|
|
a2bd23da14 | ||
|
|
a164700ab9 | ||
|
|
91c67930ec | ||
|
|
0c18d0f54f | ||
|
|
ae566d5cd1 | ||
|
|
d66d465e31 | ||
|
|
cf94112e96 | ||
|
|
5a5bc8846f | ||
|
|
7ef4c53cd8 | ||
|
|
60040e789b | ||
|
|
769f387267 | ||
|
|
202be15764 | ||
|
|
fbfcc08640 | ||
|
|
8a8cffbdf5 | ||
|
|
0bb6cae95a | ||
|
|
180490c640 | ||
|
|
b604b468b5 | ||
|
|
af064dd06b | ||
|
|
6100187274 | ||
|
|
bf002aee2c | ||
|
|
b53e3de7cc | ||
|
|
47b4ee4791 | ||
|
|
1c506fa779 | ||
|
|
e7a1e183a0 | ||
|
|
72f3bc8db8 | ||
|
|
e0a1717db2 | ||
|
|
3c2dd055fa | ||
|
|
56f8f9eeb4 | ||
|
|
b6cf32c845 | ||
|
|
21ff12cef7 | ||
|
|
a17e8561d0 | ||
|
|
e2771dfe9a | ||
|
|
7635a17599 | ||
|
|
f9bf830067 | ||
|
|
85c5d24b0e | ||
|
|
dc51e9a91b | ||
|
|
f749312b45 | ||
|
|
fa922cf8e3 | ||
|
|
208dc40041 | ||
|
|
8b555043e3 | ||
|
|
faf4484657 | ||
|
|
93230457d8 | ||
|
|
b4c79fdbbc | ||
|
|
8b9a0705b6 | ||
|
|
bc8e2b7928 | ||
|
|
9bb9bad4c7 | ||
|
|
ab126da479 | ||
|
|
ba4cf2e93d | ||
|
|
4507457560 | ||
|
|
dc24fe3afe | ||
|
|
3c9841760c | ||
|
|
51d0c14009 | ||
|
|
8a6cb6ac2c | ||
|
|
ce39fb52c9 | ||
|
|
c260ef15fb | ||
|
|
e823000301 | ||
|
|
7b71113b4d | ||
|
|
c2226177b6 | ||
|
|
749e480baf | ||
|
|
b41d9d6006 | ||
|
|
2577489c2a | ||
|
|
f89019f804 | ||
|
|
e26398b7a9 | ||
|
|
9cf1b41b77 | ||
|
|
0fe8f57dda | ||
|
|
6946534f3e | ||
|
|
1487ff60b4 | ||
|
|
d0f52cbf19 | ||
|
|
17c51eaa86 | ||
|
|
2900add223 | ||
|
|
a5fb7d2472 | ||
|
|
c5ab43ed5a | ||
|
|
a237b15559 | ||
|
|
07b23950ab | ||
|
|
bbf5ee6d59 | ||
|
|
5131fc595a | ||
|
|
e017d80256 | ||
|
|
958d470f98 | ||
|
|
af806919b3 | ||
|
|
42cd0f9f4a | ||
|
|
8d32af14d6 | ||
|
|
764e636c07 | ||
|
|
c21e756a30 | ||
|
|
6743cc11b9 | ||
|
|
c20a93602d | ||
|
|
6e774e191a | ||
|
|
852ac1f56b | ||
|
|
012b8b387a | ||
|
|
5e023dcc97 | ||
|
|
6779d903f2 | ||
|
|
3764ba0566 | ||
|
|
2c65cf05ca | ||
|
|
f87101ccaa | ||
|
|
275a362a46 | ||
|
|
1d3ee72274 | ||
|
|
be1462dc03 | ||
|
|
fa0cb12464 | ||
|
|
37dc3a38b8 | ||
|
|
876f1ee3a3 | ||
|
|
0cc3ed8868 | ||
|
|
15c9add3f4 | ||
|
|
c278e16053 | ||
|
|
f1c7054871 | ||
|
|
4204503b41 | ||
|
|
77b29fe417 | ||
|
|
a0d68ef66e | ||
|
|
f2d82a93ed | ||
|
|
e1dabd1b0f | ||
|
|
7c7aa1ac2d | ||
|
|
6be8f16351 | ||
|
|
b71d3aeab3 | ||
|
|
4d3e90e7c5 | ||
|
|
634d8a17aa | ||
|
|
ef7c15f84d | ||
|
|
b842a7f923 | ||
|
|
bc227a9a6d | ||
|
|
2cf0ad5908 | ||
|
|
077cedc0c9 | ||
|
|
c83c18e52a | ||
|
|
ce3bd784c0 | ||
|
|
5fa5760dd0 | ||
|
|
1666e55e60 | ||
|
|
1e6bd34d6f | ||
|
|
e9fdb356dd | ||
|
|
897d256f98 | ||
|
|
4ea618e8b4 | ||
|
|
240e7adf46 | ||
|
|
deb228d629 | ||
|
|
94b022b24d | ||
|
|
71b8d00897 | ||
|
|
6bf0210128 | ||
|
|
56661cd0a7 | ||
|
|
c2bba27b86 | ||
|
|
9fbe556466 | ||
|
|
407483f6e0 | ||
|
|
cf1cedf5e3 | ||
|
|
da9c0ba54c | ||
|
|
d7d2f92286 | ||
|
|
cbae9a3ecd | ||
|
|
f92d1a29c2 | ||
|
|
a47083b9b8 | ||
|
|
0ae39c231d | ||
|
|
6c653ce32a | ||
|
|
5e145957a5 | ||
|
|
46703eac3f | ||
|
|
6e024db9eb | ||
|
|
fd6f7ff22c | ||
|
|
a06af6f0e3 | ||
|
|
72c20ee1e1 | ||
|
|
eb6f006a31 | ||
|
|
3a490521e4 | ||
|
|
3c50db61eb | ||
|
|
edf26410a0 | ||
|
|
e1fbf0112a | ||
|
|
456e46d3fc | ||
|
|
25c9d20870 | ||
|
|
873bcd06e5 | ||
|
|
c41a698a08 | ||
|
|
5c4153ad57 | ||
|
|
8929286276 | ||
|
|
14f5c865b3 | ||
|
|
fd396df334 | ||
|
|
ae4dd0ff91 | ||
|
|
c73e68bd75 | ||
|
|
a73d5b351e | ||
|
|
7fdf803a50 | ||
|
|
f7621b155e | ||
|
|
6200e6bbec | ||
|
|
8d7203d701 | ||
|
|
c05b2f4c05 | ||
|
|
3976f886e4 | ||
|
|
e4d78a5013 | ||
|
|
e96f5bda7a | ||
|
|
f5f364739b | ||
|
|
a3b79736e0 | ||
|
|
096bb878d5 | ||
|
|
efb0a2d076 | ||
|
|
a5341684f6 | ||
|
|
2a28fee7f2 | ||
|
|
95603b24a8 | ||
|
|
ceb22e0d65 | ||
|
|
aae189a801 | ||
|
|
988a5cd975 | ||
|
|
52b729e86a | ||
|
|
3354da1973 | ||
|
|
bcf64928b1 | ||
|
|
d0e250e860 | ||
|
|
a4736a2346 | ||
|
|
042783076f | ||
|
|
8fc41ccf29 | ||
|
|
e09df4a348 | ||
|
|
b5a025102d | ||
|
|
5af1b796ef | ||
|
|
2b04108f93 | ||
|
|
82e6d3a3f4 | ||
|
|
4cc31a42f9 | ||
|
|
cf292e4166 | ||
|
|
d73a070b5f | ||
|
|
5e64c96851 | ||
|
|
67e34baece | ||
|
|
2f4672218c | ||
|
|
b50d265247 | ||
|
|
2a544b78d1 | ||
|
|
850b7c34b6 | ||
|
|
f464716e89 | ||
|
|
67634c399e | ||
|
|
89c9b9a4ec | ||
|
|
23c6593b07 | ||
|
|
d6f4f936e3 | ||
|
|
24d8891424 | ||
|
|
c1e47a5692 | ||
|
|
27922c67f8 | ||
|
|
95d01b07fe | ||
|
|
183e9f08b8 | ||
|
|
eb3560d949 | ||
|
|
216fa5cf46 | ||
|
|
7a28d09b5d | ||
|
|
2111c84e7d | ||
|
|
0716157fa5 | ||
|
|
5798871cc2 | ||
|
|
84ed137f66 | ||
|
|
b4e04f4dfb | ||
|
|
4faf839dab | ||
|
|
7dde2f5405 | ||
|
|
75ed0632b4 | ||
|
|
1fd929040c | ||
|
|
f2c8a8d5c1 | ||
|
|
5965904c60 | ||
|
|
70c868962a | ||
|
|
08ed5a82d2 | ||
|
|
a887da1f8f | ||
|
|
f94cb80fc9 | ||
|
|
6e27911733 | ||
|
|
9d8ab0a7b5 | ||
|
|
c502ae30d6 | ||
|
|
ff435ea13c | ||
|
|
a1ba69eebb | ||
|
|
df1104d3e7 | ||
|
|
e441b81d3b | ||
|
|
d422bc0978 | ||
|
|
ec8f6abf5b | ||
|
|
dc41b685d0 | ||
|
|
63a7ba8287 | ||
|
|
bab803ff3d | ||
|
|
4bc02a8c7d | ||
|
|
d4e89f7863 | ||
|
|
ec879df4c2 | ||
|
|
056a232ef8 | ||
|
|
8fa94a07a8 | ||
|
|
ca3de3da53 | ||
|
|
be6feabf70 | ||
|
|
1071619532 | ||
|
|
1033a22407 | ||
|
|
7966e70db6 | ||
|
|
4f466550ca | ||
|
|
6ac6b9528c | ||
|
|
4dd32bb9f7 | ||
|
|
d5d3331498 | ||
|
|
09a766be5d | ||
|
|
342974f840 | ||
|
|
f58317d079 | ||
|
|
125f52a059 | ||
|
|
3320a5b3e6 | ||
|
|
8436276991 | ||
|
|
6971a75818 | ||
|
|
25c19b26d3 | ||
|
|
d747e0a2c9 | ||
|
|
e727210aad | ||
|
|
2f5e0408fd | ||
|
|
2cd61a90fe | ||
|
|
cb974d659f | ||
|
|
1948eae184 | ||
|
|
cbce6cb2e7 | ||
|
|
00394d43b7 | ||
|
|
c89ae2eb5c | ||
|
|
380ed5fde1 | ||
|
|
5229dedb03 | ||
|
|
57ac56fcb3 | ||
|
|
7a7e9c0cbc | ||
|
|
392266c553 | ||
|
|
ab95f1b253 | ||
|
|
8dd1b2ee34 | ||
|
|
adb35f12b5 | ||
|
|
16f977fde0 | ||
|
|
8ec69cdd35 |
@@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN
|
||||
# profile's values. Leave unset for pure env-based configuration.
|
||||
GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json
|
||||
GITEA_MCP_PROFILE=prgs
|
||||
|
||||
# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only
|
||||
# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a
|
||||
# merger process) are ignored.
|
||||
# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work
|
||||
# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456
|
||||
# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456
|
||||
# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456
|
||||
# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
## Description
|
||||
|
||||
[Summary of changes and issue number closed.]
|
||||
|
||||
Closes #[Issue Number]
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have verified my identity matches the required role.
|
||||
- [ ] No secrets, tokens, keychain IDs, or raw service URLs are committed.
|
||||
- [ ] All tests pass for touched code.
|
||||
- [ ] `git diff --check` is clean.
|
||||
|
||||
## Documentation and Wiki
|
||||
|
||||
- [ ] Does this change require a wiki update (workflows, tools, profiles, runbooks)?
|
||||
- [ ] If yes, has `docs/wiki/` been updated accordingly?
|
||||
- [ ] If wiki pages changed, plan the Gitea Wiki sync after merge (`scripts/sync-gitea-wiki.sh`, see Runbooks).
|
||||
- [ ] Readiness gate (#224): the Gitea Wiki is populated and current for this repo — verify the repo **Wiki tab**, not `docs/wiki/`. If stale or empty, record the required sync as a follow-up before approval.
|
||||
- [ ] If this PR closes a wiki-related issue: closure requires live Gitea Wiki proof links (Wiki Home plus page listing or wiki git log). Markdown in `docs/wiki/`, sync-helper code, or policy docs alone are not sufficient to close a wiki issue.
|
||||
@@ -10,3 +10,7 @@ gitea-mcp*.json
|
||||
.vscode/
|
||||
graphify-out/
|
||||
branches/
|
||||
# Throwaway agent commit-encoding helpers (#261) — never commit.
|
||||
/_encode_*.py
|
||||
/_emit_*.py
|
||||
/_inline_*.py
|
||||
|
||||
@@ -53,6 +53,7 @@ Any MCP-compatible agent (Antigravity, Claude Code, etc.) can call these tools n
|
||||
| `gitea_whoami` | Read-only: identify the authenticated Gitea account (safe metadata only) |
|
||||
| `gitea_get_profile` | Read-only: describe the active runtime execution profile (safe metadata only) |
|
||||
| `gitea_check_pr_eligibility` | Read-only: check if the current identity/profile may review/approve/request_changes/merge a PR |
|
||||
| `gitea_assess_conflict_fix_classification` | Read-only: classify conflict-fix need from a live PR head re-fetch before creating a conflict-fix worktree |
|
||||
| `gitea_submit_pr_review` | Gated review mutation: comment/approve/request_changes, only after identity+profile+eligibility gates pass (no merge, no self-approval) |
|
||||
| `gitea_mark_issue` | Claim/release an issue (start/done) |
|
||||
| `gitea_list_labels` | List all available labels in a repository |
|
||||
@@ -172,6 +173,14 @@ Recognized environment fields (see [`.env.example`](.env.example) for placeholde
|
||||
| `GITEA_MCP_CONFIG` | Optional path to a JSON file defining multiple named runtime profiles. Unset ⇒ pure env behaviour. |
|
||||
| `GITEA_MCP_PROFILE` | Name of the profile (from `GITEA_MCP_CONFIG`) to activate for this runtime. |
|
||||
|
||||
#### External MCP Control Plane servers
|
||||
|
||||
Jenkins and GlitchTip are separate MCP trust boundaries, not tools inside this
|
||||
Gitea MCP runtime. Register them as `jenkins-mcp` and `glitchtip-mcp` in the
|
||||
client that will use them, then reconnect or reload the client and verify the
|
||||
expected tools are visible before claiming readiness. See
|
||||
[`docs/mcp-client-registration.md`](docs/mcp-client-registration.md).
|
||||
|
||||
Notes:
|
||||
|
||||
- This provides **one token + one profile per process**. It does not implement
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Detect throwaway agent helper scripts left in the repo root (#261)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
|
||||
AGENT_TEMP_BASENAME_PATTERNS = (
|
||||
"_encode_*.py",
|
||||
"_emit_*.py",
|
||||
"_inline_*.py",
|
||||
)
|
||||
|
||||
|
||||
def find_agent_temp_artifacts_from_porcelain(porcelain: str) -> list[str]:
|
||||
"""Return untracked repo-root helper paths matching agent temp patterns."""
|
||||
found: list[str] = []
|
||||
for line in (porcelain or "").splitlines():
|
||||
if not line.startswith("??"):
|
||||
continue
|
||||
path = line[3:].strip()
|
||||
if not path or "/" in path or "\\" in path:
|
||||
continue
|
||||
basename = path.split("/")[-1]
|
||||
if any(fnmatch.fnmatch(basename, pat) for pat in AGENT_TEMP_BASENAME_PATTERNS):
|
||||
found.append(path)
|
||||
return sorted(found)
|
||||
@@ -0,0 +1,610 @@
|
||||
"""Controller-owned work allocator policy (#600).
|
||||
|
||||
Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``).
|
||||
|
||||
Workers must not self-select exclusive work under the standard multi-LLM
|
||||
workflow. They call ``gitea_allocate_next_work`` which:
|
||||
|
||||
1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents).
|
||||
2. Applies ADR routing policy (role, terminal path, leases, blocked, deps).
|
||||
3. Atomically assigns + leases the selected item in one DB transaction.
|
||||
|
||||
This module is pure selection + substrate orchestration. Gitea I/O for live
|
||||
inventory lives in the MCP tool wrapper so tests can inject candidates.
|
||||
|
||||
#612 remains downstream: bridge-created Gitea issues become candidates only
|
||||
after they exist as normal issues; this module never assigns incidents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Sequence
|
||||
|
||||
from control_plane_db import (
|
||||
ControlPlaneDB,
|
||||
ControlPlaneError,
|
||||
InvalidWorkKindError,
|
||||
LeaseRequiredError,
|
||||
WORK_KINDS,
|
||||
)
|
||||
|
||||
# Outcomes required by #600 / ADR §5.
|
||||
OUTCOME_ASSIGNED = "assigned_work"
|
||||
OUTCOME_WAIT = "wait"
|
||||
OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path"
|
||||
OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease"
|
||||
OUTCOME_NEEDS_CONTROLLER = "needs_controller"
|
||||
OUTCOME_NO_SAFE = "no_safe_work"
|
||||
OUTCOME_ROLE_INELIGIBLE = "role_ineligible"
|
||||
OUTCOME_PREVIEW = "preview" # dry-run only (apply=false)
|
||||
|
||||
ROLE_AUTHOR = "author"
|
||||
ROLE_REVIEWER = "reviewer"
|
||||
ROLE_MERGER = "merger"
|
||||
ROLE_RECONCILER = "reconciler"
|
||||
ROLE_CONTROLLER = "controller"
|
||||
|
||||
VALID_ROLES = frozenset(
|
||||
{ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER}
|
||||
)
|
||||
|
||||
# Default action matrices by role (mutation gate will re-check).
|
||||
ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = {
|
||||
ROLE_AUTHOR: (
|
||||
("implement", "comment", "push", "create_pr"),
|
||||
("approve", "merge", "request_changes", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_REVIEWER: (
|
||||
("review", "comment", "approve", "request_changes"),
|
||||
("merge", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_MERGER: (
|
||||
("merge", "comment"),
|
||||
("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_RECONCILER: (
|
||||
("comment", "diagnose", "cleanup"),
|
||||
("approve", "merge", "push", "create_pr", "self_select_without_assignment"),
|
||||
),
|
||||
ROLE_CONTROLLER: (
|
||||
("comment", "diagnose", "allocate"),
|
||||
("approve", "merge", "push", "create_pr"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkCandidate:
|
||||
"""One assignable Gitea issue or PR presented to the allocator."""
|
||||
|
||||
kind: str # issue | pr
|
||||
number: int
|
||||
state: str = "open"
|
||||
labels: tuple[str, ...] = ()
|
||||
title: str = ""
|
||||
priority: int = 0
|
||||
head_sha: str | None = None
|
||||
# Routing signals (callers derive from Gitea / review feedback).
|
||||
request_changes_current_head: bool = False
|
||||
approval_on_current_head: bool = False
|
||||
approval_stale: bool = False
|
||||
approval_contaminated: bool = False
|
||||
mergeable: bool = False
|
||||
blocked: bool = False
|
||||
dependency_unmet: bool = False
|
||||
dependency_reason: str | None = None
|
||||
already_claimed_elsewhere: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.kind = (self.kind or "").strip().lower()
|
||||
self.state = (self.state or "open").strip().lower()
|
||||
self.labels = tuple(
|
||||
str(x).strip().lower() for x in (self.labels or ()) if str(x).strip()
|
||||
)
|
||||
if self.kind not in WORK_KINDS:
|
||||
raise InvalidWorkKindError(
|
||||
f"candidate kind '{self.kind}' is not assignable; only "
|
||||
f"{sorted(WORK_KINDS)} (never raw incidents)"
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"number": self.number,
|
||||
"state": self.state,
|
||||
"labels": list(self.labels),
|
||||
"title": self.title,
|
||||
"priority": self.priority,
|
||||
"head_sha": self.head_sha,
|
||||
"request_changes_current_head": self.request_changes_current_head,
|
||||
"approval_on_current_head": self.approval_on_current_head,
|
||||
"approval_stale": self.approval_stale,
|
||||
"approval_contaminated": self.approval_contaminated,
|
||||
"mergeable": self.mergeable,
|
||||
"blocked": self.blocked,
|
||||
"dependency_unmet": self.dependency_unmet,
|
||||
"dependency_reason": self.dependency_reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkipRecord:
|
||||
kind: str
|
||||
number: int
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"kind": self.kind, "number": self.number, "reason": self.reason}
|
||||
|
||||
|
||||
def normalize_role(role: str | None, *, profile_name: str | None = None) -> str:
|
||||
"""Map profile/role strings to a canonical allocator role."""
|
||||
raw = (role or "").strip().lower()
|
||||
if raw in VALID_ROLES:
|
||||
return raw
|
||||
prof = (profile_name or "").strip().lower()
|
||||
for token in VALID_ROLES:
|
||||
if token in prof or prof.endswith(f"-{token}"):
|
||||
return token
|
||||
if "author" in raw:
|
||||
return ROLE_AUTHOR
|
||||
if "review" in raw:
|
||||
return ROLE_REVIEWER
|
||||
if "merg" in raw:
|
||||
return ROLE_MERGER
|
||||
if "reconcil" in raw:
|
||||
return ROLE_RECONCILER
|
||||
if "control" in raw:
|
||||
return ROLE_CONTROLLER
|
||||
raise ControlPlaneError(
|
||||
f"unknown allocator role '{role}' (profile={profile_name!r}); "
|
||||
f"expected one of {sorted(VALID_ROLES)}"
|
||||
)
|
||||
|
||||
|
||||
def expected_role_for_candidate(c: WorkCandidate) -> str:
|
||||
"""ADR §5.3 routing: which role should take this work next."""
|
||||
if c.kind == "pr":
|
||||
if c.approval_contaminated:
|
||||
return ROLE_RECONCILER
|
||||
if c.request_changes_current_head:
|
||||
return ROLE_AUTHOR
|
||||
if c.approval_stale:
|
||||
return ROLE_REVIEWER
|
||||
if c.approval_on_current_head and c.mergeable:
|
||||
return ROLE_MERGER
|
||||
# Open PR without terminal verdict → reviewer
|
||||
return ROLE_REVIEWER
|
||||
# Issues: ready work → author by default; blocked stays controller/none
|
||||
labels = set(c.labels)
|
||||
if "status:blocked" in labels or c.blocked:
|
||||
return ROLE_CONTROLLER
|
||||
return ROLE_AUTHOR
|
||||
|
||||
|
||||
def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR])
|
||||
|
||||
|
||||
def classify_skip(
|
||||
c: WorkCandidate,
|
||||
*,
|
||||
role: str,
|
||||
terminal_pr: int | None,
|
||||
) -> str | None:
|
||||
"""Return skip reason, or None if candidate is selectable for *role*."""
|
||||
if c.state in ("merged", "closed"):
|
||||
return f"{c.kind}#{c.number} is {c.state}; never assign"
|
||||
if c.blocked or "status:blocked" in c.labels:
|
||||
return f"{c.kind}#{c.number} is blocked"
|
||||
if c.dependency_unmet:
|
||||
return (
|
||||
c.dependency_reason
|
||||
or f"{c.kind}#{c.number} has unmet dependencies"
|
||||
)
|
||||
if c.already_claimed_elsewhere:
|
||||
return f"{c.kind}#{c.number} already claimed elsewhere"
|
||||
if c.kind == "pr" and not (c.head_sha or "").strip():
|
||||
return f"pr#{c.number} missing head_sha pin"
|
||||
|
||||
# Terminal path first: when an active terminal PR exists, only that PR
|
||||
# (or controller diagnosis) is assignable for review-path roles.
|
||||
if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr:
|
||||
if role in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
return (
|
||||
f"pr#{c.number} skipped: active terminal-review lock on "
|
||||
f"PR #{terminal_pr} must be resolved first"
|
||||
)
|
||||
|
||||
expected = expected_role_for_candidate(c)
|
||||
if role == ROLE_CONTROLLER:
|
||||
# Controller may inspect anything but only assigns diagnosis targets
|
||||
# when contaminated / blocked.
|
||||
if expected == ROLE_RECONCILER or c.blocked:
|
||||
return None
|
||||
return f"{c.kind}#{c.number} does not require controller (expected {expected})"
|
||||
|
||||
if role != expected:
|
||||
return (
|
||||
f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'"
|
||||
)
|
||||
|
||||
# Ready-gate for issues: prefer status:ready when labels present.
|
||||
if c.kind == "issue" and c.labels:
|
||||
if "status:ready" not in c.labels and "status:in-progress" not in c.labels:
|
||||
# Allow unlabeled open issues; only skip explicit non-ready states.
|
||||
if any(l.startswith("status:") for l in c.labels):
|
||||
return f"issue#{c.number} not status:ready ({','.join(c.labels)})"
|
||||
return None
|
||||
|
||||
|
||||
def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]:
|
||||
"""Higher priority first; then lower number (older issues) for stability."""
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)),
|
||||
)
|
||||
|
||||
|
||||
def allocate_next_work(
|
||||
db: ControlPlaneDB,
|
||||
*,
|
||||
session_id: str,
|
||||
role: str,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
candidates: Sequence[WorkCandidate],
|
||||
apply: bool = False,
|
||||
profile_name: str | None = None,
|
||||
username: str | None = None,
|
||||
lease_ttl_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Select and optionally reserve the next work unit via control-plane DB.
|
||||
|
||||
*apply=False* (default): dry-run selection only — no lease/assignment.
|
||||
*apply=True*: atomic ``assign_and_lease`` for the selected candidate.
|
||||
|
||||
Never uses file locks or comment-only leases as the assignment source.
|
||||
"""
|
||||
if db is None:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
"control-plane DB substrate unavailable (fail closed, #600/#613)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
try:
|
||||
role_norm = normalize_role(role, profile_name=profile_name)
|
||||
except ControlPlaneError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_ROLE_INELIGIBLE,
|
||||
"reasons": [str(exc)],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}"
|
||||
try:
|
||||
db.upsert_session(
|
||||
session_id=session_id,
|
||||
role=role_norm,
|
||||
profile=profile_name,
|
||||
pid=os.getpid(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — surface structured
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [
|
||||
f"failed to register session in control-plane DB: {exc} "
|
||||
"(fail closed, #613)"
|
||||
],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
# Expire stale leases globally before selection.
|
||||
try:
|
||||
db.expire_stale_leases()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [f"lease expiry failed: {exc} (fail closed)"],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
|
||||
terminal = None
|
||||
try:
|
||||
terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"reasons": [f"terminal lock lookup failed: {exc} (fail closed)"],
|
||||
"skipped": [],
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
}
|
||||
terminal_pr = int(terminal["terminal_pr"]) if terminal else None
|
||||
|
||||
skipped: list[SkipRecord] = []
|
||||
ordered = sort_candidates(list(candidates))
|
||||
selected: WorkCandidate | None = None
|
||||
for c in ordered:
|
||||
reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr)
|
||||
if reason:
|
||||
skipped.append(SkipRecord(c.kind, c.number, reason))
|
||||
continue
|
||||
selected = c
|
||||
break
|
||||
|
||||
if selected is None:
|
||||
# If terminal lock blocks all review work, surface that explicitly.
|
||||
if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER):
|
||||
outcome = OUTCOME_BLOCKED_TERMINAL
|
||||
reasons = [
|
||||
f"no safe work for role '{role_norm}': active terminal-review "
|
||||
f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)"
|
||||
]
|
||||
else:
|
||||
outcome = OUTCOME_NO_SAFE
|
||||
reasons = [
|
||||
f"no safe assignable work for role '{role_norm}' "
|
||||
f"among {len(ordered)} candidates"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": outcome,
|
||||
"apply": bool(apply),
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": None,
|
||||
"expected_role_next": None,
|
||||
"reasons": reasons,
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
expected_role = expected_role_for_candidate(selected)
|
||||
allowed, forbidden = role_actions(role_norm)
|
||||
selection = {
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"title": selected.title,
|
||||
"labels": list(selected.labels),
|
||||
"head_sha": selected.head_sha,
|
||||
"priority": selected.priority,
|
||||
"expected_role_next": expected_role,
|
||||
"reason_selected": (
|
||||
f"highest-priority candidate for role '{role_norm}' "
|
||||
f"(expected_role={expected_role})"
|
||||
),
|
||||
}
|
||||
|
||||
if not apply:
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_PREVIEW,
|
||||
"apply": False,
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [
|
||||
"dry-run only (apply=false); no assignment/lease created — "
|
||||
"call again with apply=true to reserve via control-plane DB"
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
# Atomic reserve via #613 substrate.
|
||||
ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None
|
||||
try:
|
||||
kwargs: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"role": role_norm,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"kind": selected.kind,
|
||||
"number": selected.number,
|
||||
"expected_head_sha": selected.head_sha,
|
||||
"allowed_actions": allowed,
|
||||
"forbidden_actions": forbidden,
|
||||
"phase": "allocated",
|
||||
}
|
||||
if ttl is not None:
|
||||
kwargs["lease_ttl_seconds"] = int(ttl)
|
||||
result = db.assign_and_lease(**kwargs)
|
||||
except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": None,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
if result.outcome == "wait":
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_WAIT,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [result.reason or "foreign active lease"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"owner_session_id": result.owner_session_id,
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
if result.outcome == "no_safe_work":
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_NO_SAFE,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [result.reason or "no_safe_work"],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
# assigned
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": OUTCOME_ASSIGNED,
|
||||
"apply": True,
|
||||
"role": role_norm,
|
||||
"profile_name": profile_name,
|
||||
"username": username,
|
||||
"session_id": session_id,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"selected": selection,
|
||||
"expected_role_next": expected_role,
|
||||
"reasons": [
|
||||
selection["reason_selected"],
|
||||
result.reason or "atomic assign+lease created",
|
||||
],
|
||||
"skipped": [s.as_dict() for s in skipped],
|
||||
"terminal_pr": terminal_pr,
|
||||
"assignment": result.as_dict(),
|
||||
"lease_proof": {
|
||||
"assignment_id": result.assignment_id,
|
||||
"lease_id": result.lease_id,
|
||||
"expires_at": result.expires_at,
|
||||
"expected_head_sha": result.expected_head_sha,
|
||||
"allowed_actions": list(result.allowed_actions),
|
||||
"forbidden_actions": list(result.forbidden_actions),
|
||||
"source": "control_plane_db.assign_and_lease",
|
||||
},
|
||||
"next_valid_command": _next_command(role_norm, selected),
|
||||
"substrate": "control_plane_db",
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"downstream_note": (
|
||||
"#612 incident bridge remains downstream of #600; "
|
||||
"allocator never assigns raw monitoring incidents"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _next_command(role: str, c: WorkCandidate) -> str:
|
||||
if role == ROLE_AUTHOR and c.kind == "issue":
|
||||
return f"implement issue #{c.number} under a branches/ worktree; open PR when ready"
|
||||
if role == ROLE_AUTHOR and c.kind == "pr":
|
||||
return (
|
||||
f"address REQUEST_CHANGES on PR #{c.number} at head "
|
||||
f"{(c.head_sha or '')[:12]} and push fixes"
|
||||
)
|
||||
if role == ROLE_REVIEWER:
|
||||
return (
|
||||
f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} "
|
||||
"via full reviewer workflow"
|
||||
)
|
||||
if role == ROLE_MERGER:
|
||||
return (
|
||||
f"merge PR #{c.number} only with explicit operator MERGE "
|
||||
f"authorization at head {(c.head_sha or '')[:12]}"
|
||||
)
|
||||
if role == ROLE_RECONCILER:
|
||||
return f"diagnose contested state for {c.kind}#{c.number}"
|
||||
return f"proceed on {c.kind}#{c.number} under role {role}"
|
||||
|
||||
|
||||
def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate:
|
||||
"""Build a WorkCandidate from a plain dict (tests / MCP inventory)."""
|
||||
return WorkCandidate(
|
||||
kind=str(data.get("kind") or "issue"),
|
||||
number=int(data["number"]),
|
||||
state=str(data.get("state") or "open"),
|
||||
labels=tuple(data.get("labels") or ()),
|
||||
title=str(data.get("title") or ""),
|
||||
priority=int(data.get("priority") or 0),
|
||||
head_sha=data.get("head_sha"),
|
||||
request_changes_current_head=bool(data.get("request_changes_current_head")),
|
||||
approval_on_current_head=bool(data.get("approval_on_current_head")),
|
||||
approval_stale=bool(data.get("approval_stale")),
|
||||
approval_contaminated=bool(data.get("approval_contaminated")),
|
||||
mergeable=bool(data.get("mergeable")),
|
||||
blocked=bool(data.get("blocked")),
|
||||
dependency_unmet=bool(data.get("dependency_unmet")),
|
||||
dependency_reason=data.get("dependency_reason"),
|
||||
already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")),
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Already-landed open PR reconciliation gates (#310).
|
||||
|
||||
Reconciler workflows may close an open PR only when the PR head SHA is proven
|
||||
an ancestor of a freshly fetched target branch. Arbitrary PR closure is denied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def assess_already_landed_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return eligibility and proof for reconciling an open PR."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None
|
||||
if not head_sha and isinstance(pr.get("head"), str):
|
||||
head_sha = None
|
||||
head_ref = (pr.get("head") or {}).get("ref") if isinstance(pr.get("head"), dict) else pr.get("head")
|
||||
base_ref = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base")
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(project_root, remote, target_branch)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["close_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["close_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Audit vs cleanup phase gates for reconciliation workflows (#419).
|
||||
|
||||
Audit/reconciliation tasks are read-only unless a separate cleanup phase is
|
||||
explicitly authorized with exact capability proof, safety proof, and
|
||||
before/after snapshots. Cleanup mutations must be classified in final reports;
|
||||
audit reports must not claim ``no mutations`` when cleanup occurred.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md"
|
||||
|
||||
PHASE_AUDIT = "audit"
|
||||
PHASE_CLEANUP = "cleanup"
|
||||
|
||||
# Tasks that enter audit phase on capability resolution (read-only default).
|
||||
AUDIT_PHASE_TASKS = frozenset({
|
||||
"reconcile-landed-pr",
|
||||
"reconcile_landed_pr",
|
||||
"reconcile_issue_claims",
|
||||
"reconcile_merged_cleanups",
|
||||
})
|
||||
|
||||
# Mutation tasks forbidden during audit phase (fail closed).
|
||||
AUDIT_FORBIDDEN_TASKS = frozenset({
|
||||
"delete_branch",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"claim_issue",
|
||||
"close_pr",
|
||||
"close_issue",
|
||||
"create_issue",
|
||||
"merge_pr",
|
||||
"review_pr",
|
||||
"submit_pr_review",
|
||||
"comment_pr",
|
||||
"comment_issue",
|
||||
"set_issue_labels",
|
||||
})
|
||||
|
||||
# Shell/git commands audit phase must not run.
|
||||
AUDIT_FORBIDDEN_COMMAND_RE = re.compile(
|
||||
r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|"
|
||||
r"gitea_delete_branch|delete_remote_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_NO_MUTATIONS_RE = re.compile(
|
||||
r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_OCCURRED_RE = re.compile(
|
||||
r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|"
|
||||
r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|"
|
||||
r"cleanup\s+phase\s*:\s*(?!none\b)\S)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_STATE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*external[- ]state mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_PHASE_AUTH_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BEFORE_AFTER_RE = re.compile(
|
||||
r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_SAFETY_PROOF_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_session: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _blank_session() -> dict[str, Any]:
|
||||
return {
|
||||
"phase": PHASE_AUDIT,
|
||||
"entered_from_task": None,
|
||||
"cleanup_authorized": False,
|
||||
"cleanup_authorization": {},
|
||||
}
|
||||
|
||||
|
||||
def current_phase() -> str | None:
|
||||
"""Return active reconciliation phase or None when unset."""
|
||||
if not _session:
|
||||
return None
|
||||
return _session.get("phase")
|
||||
|
||||
|
||||
def active_record() -> dict[str, Any] | None:
|
||||
"""Return a copy of the session record, if any."""
|
||||
return dict(_session) if _session else None
|
||||
|
||||
|
||||
def clear_phase() -> None:
|
||||
"""Clear reconciliation phase state."""
|
||||
global _session
|
||||
_session = None
|
||||
|
||||
|
||||
def enter_audit_phase(task: str) -> dict[str, Any]:
|
||||
"""Enter read-only audit phase for a reconciliation task."""
|
||||
global _session
|
||||
normalized = (task or "").strip().lower()
|
||||
_session = _blank_session()
|
||||
_session["entered_from_task"] = normalized
|
||||
return dict(_session)
|
||||
|
||||
|
||||
def authorize_cleanup_phase(
|
||||
*,
|
||||
operator_approved: bool = False,
|
||||
workflow_authorized: bool = False,
|
||||
delete_capability_proven: bool = False,
|
||||
safety_proof: dict[str, Any] | None = None,
|
||||
before_after_snapshot: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Authorize cleanup phase after explicit approval and safety proofs."""
|
||||
reasons: list[str] = []
|
||||
if not (operator_approved or workflow_authorized):
|
||||
reasons.append(
|
||||
"cleanup phase requires operator approval or explicit workflow "
|
||||
"authorization"
|
||||
)
|
||||
if not delete_capability_proven:
|
||||
reasons.append(
|
||||
"cleanup phase requires exact delete_branch capability proof "
|
||||
"(gitea.branch.delete)"
|
||||
)
|
||||
safety = dict(safety_proof or {})
|
||||
if not safety.get("safe_to_delete_remote") and not safety.get(
|
||||
"safe_to_remove_worktree"
|
||||
):
|
||||
reasons.append(
|
||||
"cleanup phase requires proof that branch/worktree is safe to remove"
|
||||
)
|
||||
snapshot = dict(before_after_snapshot or {})
|
||||
if not snapshot.get("before") or not snapshot.get("after"):
|
||||
reasons.append(
|
||||
"cleanup phase requires before/after state snapshot"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"authorized": False,
|
||||
"phase": current_phase() or PHASE_AUDIT,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remain in audit-only mode or supply operator approval, "
|
||||
"delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot before cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = _blank_session()
|
||||
_session["phase"] = PHASE_CLEANUP
|
||||
_session["cleanup_authorized"] = True
|
||||
_session["cleanup_authorization"] = {
|
||||
"operator_approved": operator_approved,
|
||||
"workflow_authorized": workflow_authorized,
|
||||
"delete_capability_proven": delete_capability_proven,
|
||||
"safety_proof": safety,
|
||||
"before_after_snapshot": snapshot,
|
||||
}
|
||||
return {
|
||||
"authorized": True,
|
||||
"phase": PHASE_CLEANUP,
|
||||
"reasons": [],
|
||||
"cleanup_authorization": dict(_session["cleanup_authorization"]),
|
||||
"safe_next_action": "proceed with authorized cleanup mutations only",
|
||||
}
|
||||
|
||||
|
||||
def check_audit_task_enters_phase(task: str) -> bool:
|
||||
"""Return whether resolving *task* should enter audit phase."""
|
||||
return (task or "").strip().lower() in AUDIT_PHASE_TASKS
|
||||
|
||||
|
||||
def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed when a mutation task runs during audit phase."""
|
||||
normalized = (task or "").strip().lower()
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
if normalized in AUDIT_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in audit-only reconciliation "
|
||||
"mode: switch to an explicit cleanup phase with operator approval "
|
||||
"and exact delete_branch capability proof before cleanup mutations"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
def check_cleanup_execution_allowed() -> tuple[bool, list[str]]:
|
||||
"""Fail closed when cleanup execution is attempted without authorization."""
|
||||
phase = current_phase()
|
||||
if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"):
|
||||
return True, []
|
||||
if phase is None:
|
||||
return False, [
|
||||
"cleanup execution requires an active reconciliation session; "
|
||||
"resolve a reconciliation audit task first"
|
||||
]
|
||||
return False, [
|
||||
"cleanup execution forbidden in audit-only reconciliation mode; "
|
||||
"call gitea_authorize_reconciliation_cleanup_phase with operator "
|
||||
"approval, delete_branch capability proof, safety proof, and "
|
||||
"before/after snapshot"
|
||||
]
|
||||
|
||||
|
||||
def classify_cleanup_mutation(action: str) -> str:
|
||||
"""Map a cleanup action to the required mutation ledger category (#419)."""
|
||||
normalized = (action or "").strip().lower()
|
||||
if "delete_remote" in normalized or normalized in {
|
||||
"delete_branch",
|
||||
"gitea_delete_branch",
|
||||
}:
|
||||
return "external-state"
|
||||
if "branch" in normalized and "delete" in normalized:
|
||||
return "git-ref"
|
||||
if "worktree" in normalized or "remove_local" in normalized:
|
||||
return "cleanup"
|
||||
return "cleanup"
|
||||
|
||||
|
||||
def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]:
|
||||
"""Validate audit/cleanup reconciliation reports (fail closed)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text))
|
||||
claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text))
|
||||
|
||||
if cleanup_occurred and claims_no_mutations:
|
||||
reasons.append(
|
||||
"report claims no mutations but documents cleanup mutations; "
|
||||
"audit-only reports must not perform cleanup and cleanup reports "
|
||||
"must not claim no mutations"
|
||||
)
|
||||
|
||||
if cleanup_occurred:
|
||||
if not _CLEANUP_PHASE_AUTH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without "
|
||||
"'Cleanup phase authorized: true'"
|
||||
)
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without delete_branch capability "
|
||||
"proof"
|
||||
)
|
||||
if not _BEFORE_AFTER_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without before/after state snapshot"
|
||||
)
|
||||
if not _SAFETY_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup mutations reported without branch/worktree safety proof"
|
||||
)
|
||||
|
||||
if re.search(r"delete_remote|remote branch.*delet", text, re.I):
|
||||
if not _EXTERNAL_STATE_RE.search(text):
|
||||
reasons.append(
|
||||
"remote branch deletion must be classified under "
|
||||
"External-state mutations"
|
||||
)
|
||||
if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I):
|
||||
if not _GIT_REF_RE.search(text):
|
||||
reasons.append(
|
||||
"local branch deletion must be classified under "
|
||||
"Git ref mutations"
|
||||
)
|
||||
if re.search(r"worktree.*remov|remove_local_worktree", text, re.I):
|
||||
if not _CLEANUP_MUTATIONS_RE.search(text):
|
||||
reasons.append(
|
||||
"worktree removal must be classified under Cleanup mutations"
|
||||
)
|
||||
|
||||
if (
|
||||
RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text
|
||||
or "reconcile-landed-pr" in text.lower()
|
||||
):
|
||||
if cleanup_occurred and "audit phase" in text.lower():
|
||||
if "cleanup phase" not in text.lower():
|
||||
reasons.append(
|
||||
"report mixes audit phase with cleanup mutations without "
|
||||
"documenting cleanup phase transition"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"cleanup_occurred": cleanup_occurred,
|
||||
"claims_no_mutations": claims_no_mutations,
|
||||
"safe_next_action": (
|
||||
"proceed"
|
||||
if proven
|
||||
else "fix audit/cleanup report: separate audit from cleanup phase, "
|
||||
"classify mutations, and do not claim no mutations after cleanup"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]:
|
||||
"""Block shell commands that perform cleanup during audit phase."""
|
||||
phase = current_phase()
|
||||
if phase != PHASE_AUDIT:
|
||||
return True, []
|
||||
cmd = (command or "").strip()
|
||||
if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd):
|
||||
return False, [
|
||||
f"command forbidden in audit-only reconciliation mode: {cmd!r}; "
|
||||
"authorize cleanup phase before branch/worktree deletion or push"
|
||||
]
|
||||
return True, []
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Branches-only author mutation worktree guard (#274).
|
||||
|
||||
Author/coder mutations must run from a session-owned worktree under the
|
||||
project's ``branches/`` directory, never from the stable control checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
|
||||
# via namespace_workspace_binding (#510).
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return (path or "").replace("\\", "/").rstrip("/")
|
||||
|
||||
|
||||
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
|
||||
"""True when *path* resolves inside ``<project_root>/branches/``."""
|
||||
normalized = _normalize_path(path)
|
||||
if not normalized:
|
||||
return False
|
||||
if "/branches/" in f"{normalized}/":
|
||||
return True
|
||||
if normalized.endswith("/branches"):
|
||||
return True
|
||||
if project_root:
|
||||
root = _normalize_path(os.path.realpath(project_root))
|
||||
real = _normalize_path(os.path.realpath(path))
|
||||
if real.startswith(f"{root}/"):
|
||||
rel = real[len(root) + 1 :]
|
||||
return rel == "branches" or rel.startswith("branches/")
|
||||
return False
|
||||
|
||||
|
||||
def resolve_mutation_workspace(
|
||||
worktree_path: str | None,
|
||||
project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the workspace path inspected before author mutations."""
|
||||
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text))
|
||||
return os.path.realpath(project_root)
|
||||
|
||||
|
||||
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
|
||||
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
|
||||
raw = (common_dir or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
if os.path.isabs(raw):
|
||||
return os.path.realpath(raw)
|
||||
return os.path.realpath(os.path.join(workspace_path, raw))
|
||||
|
||||
|
||||
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
|
||||
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
|
||||
path = (workspace_path or "").strip()
|
||||
fallback = os.path.realpath(fallback_project_root)
|
||||
if not path:
|
||||
return fallback
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common = _realpath_git_common_dir(path, res.stdout)
|
||||
except Exception:
|
||||
return fallback
|
||||
if common.endswith(f"{os.sep}.git"):
|
||||
return os.path.dirname(common)
|
||||
if os.path.basename(common) == ".git":
|
||||
return os.path.dirname(common)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_author_mutation_context(
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
*,
|
||||
active_worktree_env: str | None = None,
|
||||
author_worktree_env: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
|
||||
workspace = resolve_mutation_workspace(
|
||||
worktree_path,
|
||||
process_project_root,
|
||||
active_worktree_env=active_worktree_env,
|
||||
author_worktree_env=author_worktree_env,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
# Canonical repository identity comes from the MCP process checkout (#460),
|
||||
# not from the declared task workspace being validated.
|
||||
canonical_root = resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_workspace_repo_membership(
|
||||
*,
|
||||
workspace_path: str,
|
||||
canonical_repo_root: str,
|
||||
) -> dict:
|
||||
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
root = os.path.realpath(canonical_repo_root)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not os.path.exists(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' does not exist")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
if not os.path.isdir(workspace):
|
||||
reasons.append(f"worktree path '{workspace}' is not a directory")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
common_dir = _realpath_git_common_dir(workspace, res.stdout)
|
||||
except Exception:
|
||||
reasons.append(f"worktree '{workspace}' is not a valid git repository")
|
||||
return _membership_assessment(False, reasons, workspace, root, None)
|
||||
|
||||
expected_dir = os.path.realpath(os.path.join(root, ".git"))
|
||||
if common_dir != expected_dir:
|
||||
reasons.append(
|
||||
f"worktree '{workspace}' does not belong to the target repository '{root}'"
|
||||
)
|
||||
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
|
||||
|
||||
|
||||
def _membership_assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
workspace: str,
|
||||
root: str,
|
||||
common_dir: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"workspace_path": workspace,
|
||||
"canonical_repo_root": root,
|
||||
"git_common_dir": common_dir,
|
||||
}
|
||||
|
||||
|
||||
def format_workspace_repo_membership_error(assessment: dict) -> str:
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("canonical_repo_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
|
||||
f"canonical repository root: {root}; workspace: {workspace}."
|
||||
)
|
||||
|
||||
|
||||
def assess_author_mutation_worktree(
|
||||
*,
|
||||
workspace_path: str,
|
||||
project_root: str,
|
||||
current_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when author mutations are not rooted under ``branches/``."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
root = os.path.realpath(project_root)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
branch = (current_branch or "").strip()
|
||||
|
||||
under_branches = is_path_under_branches(workspace, root)
|
||||
if not under_branches:
|
||||
if workspace == root:
|
||||
reasons.append(
|
||||
"author mutation blocked: workspace is the stable control checkout; "
|
||||
"create or switch to a session-owned worktree under branches/"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
f"author mutation blocked: workspace '{workspace}' is not under "
|
||||
f"'{root}/branches/'; create a branches/<task> worktree first"
|
||||
)
|
||||
|
||||
if not under_branches and workspace == root and branch and branch not in bases:
|
||||
reasons.append(
|
||||
f"control checkout drift: branch '{branch}' is not a stable base "
|
||||
f"branch ({'/'.join(sorted(bases))})"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"project_root": root,
|
||||
"workspace_path": workspace,
|
||||
"under_branches": is_path_under_branches(workspace, root),
|
||||
"current_branch": branch or None,
|
||||
}
|
||||
|
||||
|
||||
def format_author_mutation_worktree_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for MCP preflight gates."""
|
||||
workspace = assessment.get("workspace_path") or "(unknown)"
|
||||
root = assessment.get("project_root") or "(unknown)"
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
|
||||
return (
|
||||
f"Branches-only mutation guard (#274): {reasons}. "
|
||||
f"project root: {root}; workspace: {workspace}. "
|
||||
"Create a session-owned worktree under branches/ before mutating."
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Guards for merged-PR branch cleanup and raw git delete bypasses (#514)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
|
||||
)
|
||||
|
||||
|
||||
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw git branch-delete commands cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
||||
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||
return list(dict.fromkeys(commands))
|
||||
|
||||
|
||||
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when a report uses raw git branch deletion as cleanup proof."""
|
||||
commands = raw_branch_delete_commands(text)
|
||||
reasons = [
|
||||
(
|
||||
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
||||
f"{command}"
|
||||
)
|
||||
for command in commands
|
||||
]
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"commands": commands,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use gitea_cleanup_merged_pr_branch or another approved cleanup "
|
||||
"helper with explicit branch.delete capability"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merged_pr_branch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
remote_branch_exists: bool,
|
||||
open_pr_heads: set[str],
|
||||
head_on_target: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
confirmation: str | None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a merged PR source branch can be deleted via MCP."""
|
||||
protected = protected_branches or PROTECTED_BRANCHES
|
||||
expected_confirmation = f"CLEANUP MERGED PR {pr_number} BRANCH {head_branch}"
|
||||
reasons: list[str] = []
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
if not remote_branch_exists:
|
||||
reasons.append("remote branch already absent")
|
||||
if not head_branch:
|
||||
reasons.append("PR head branch is missing")
|
||||
if head_branch in protected:
|
||||
reasons.append(f"branch '{head_branch}' is protected")
|
||||
if head_branch in open_pr_heads:
|
||||
reasons.append("an open PR still references this head branch")
|
||||
if head_on_target is False:
|
||||
reasons.append("PR head is not an ancestor of the target branch")
|
||||
if head_on_target is None:
|
||||
reasons.append("PR head ancestry could not be proven")
|
||||
if not delete_capability_allowed:
|
||||
reasons.append("gitea.branch.delete capability is not allowed")
|
||||
if confirmation != expected_confirmation:
|
||||
reasons.append(
|
||||
"confirmation must equal "
|
||||
f"'{expected_confirmation}' for branch cleanup"
|
||||
)
|
||||
|
||||
safe = not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"expected_confirmation": expected_confirmation,
|
||||
"remote_branch_exists": remote_branch_exists,
|
||||
"safe_to_delete": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Fail-closed validation for workflow-changing Gitea comments (#496)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
VALID_ROLES = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_BASE_REQUIRED = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT", "WHY")
|
||||
|
||||
_ISSUE_REQUIRED = _BASE_REQUIRED + ("BLOCKERS", "VALIDATION")
|
||||
_PR_REQUIRED = _BASE_REQUIRED + (
|
||||
"ISSUE",
|
||||
"HEAD_SHA",
|
||||
"REVIEW_STATUS",
|
||||
"MERGE_READY",
|
||||
"BLOCKERS",
|
||||
"VALIDATION",
|
||||
)
|
||||
_SUPERSESSION_REQUIRED = _BASE_REQUIRED + (
|
||||
"CANONICAL_ITEM",
|
||||
"SUPERSEDED_ITEM",
|
||||
"CLOSE_OR_KEEP_OPEN",
|
||||
)
|
||||
|
||||
_MACHINE_MARKERS = (
|
||||
"<!-- mcp-review-lease:v1 -->",
|
||||
"<!-- mcp-conflict-fix-lease:v1 -->",
|
||||
"<!-- gitea-issue-claim-heartbeat:v1 -->",
|
||||
)
|
||||
|
||||
_WORKFLOW_TRIGGERS = re.compile(
|
||||
r"\b(?:"
|
||||
r"blocked|unblocked|ready(?:\s+for\s+(?:review|merge|author))?|"
|
||||
r"ready-to-merge|approved|approve|request\s+changes|changes\s+requested|"
|
||||
r"superseded|duplicate|canonical|next\s+action|next\s+actor|who\s+is\s+next|"
|
||||
r"author\s+should|reviewer\s+should|merger\s+should|reconciler\s+should|"
|
||||
r"controller\s+should|issue\s+complete|pr\s+open|pr\s+merged|close\s+this|"
|
||||
r"do\s+not\s+merge|needs?\s+rebase|stale\s+approval|contaminated\s+review|"
|
||||
r"merge\s+ready|ready\s+for\s+merge"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_CANONICAL_HEADINGS = (
|
||||
"## Canonical Issue State",
|
||||
"## Canonical PR State",
|
||||
"## Canonical Discussion Summary",
|
||||
)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_VAGUE_NEXT_ACTIONS = frozenset({
|
||||
"continue",
|
||||
"handle this",
|
||||
"fix it",
|
||||
"follow up",
|
||||
"follow-up",
|
||||
"see above",
|
||||
"see review",
|
||||
"tbd",
|
||||
"todo",
|
||||
"as needed",
|
||||
"proceed",
|
||||
"next steps",
|
||||
"will check",
|
||||
"investigate",
|
||||
})
|
||||
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_SHORT_SHA_RE = re.compile(r"\b[0-9a-f]{7,40}\b", re.IGNORECASE)
|
||||
_PR_REF_RE = re.compile(r"(?:PR\s*#|pull\s*#)\d+|\b#\d{2,}\b", re.IGNORECASE)
|
||||
_APPROVAL_PROOF_RE = re.compile(
|
||||
r"\b(?:approved|approval_at_current_head|APPROVE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNBLOCK_RE = re.compile(
|
||||
r"\b(?:unblock|until|after|once|when|requires?|must)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_fields(body: str) -> dict[str, str]:
|
||||
"""Parse KEY: value fields, including values continued on following lines."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
current_lines: list[str] = []
|
||||
|
||||
def _flush() -> None:
|
||||
nonlocal current_key, current_lines
|
||||
if current_key is not None:
|
||||
fields[current_key] = "\n".join(current_lines).strip()
|
||||
current_key = None
|
||||
current_lines = []
|
||||
|
||||
for line in (body or "").splitlines():
|
||||
if line.startswith("## "):
|
||||
_flush()
|
||||
continue
|
||||
match = re.match(r"^([A-Z][A-Z0-9_]*)\s*:\s*(.*)$", line)
|
||||
if match:
|
||||
_flush()
|
||||
current_key = match.group(1).strip().upper()
|
||||
rest = match.group(2)
|
||||
current_lines = [rest] if rest else []
|
||||
elif current_key is not None:
|
||||
current_lines.append(line)
|
||||
_flush()
|
||||
return fields
|
||||
|
||||
|
||||
def _is_machine_generated(body: str) -> bool:
|
||||
text = body or ""
|
||||
return any(marker in text for marker in _MACHINE_MARKERS)
|
||||
|
||||
|
||||
def _has_canonical_heading(body: str) -> bool:
|
||||
return any(h in (body or "") for h in _CANONICAL_HEADINGS)
|
||||
|
||||
|
||||
def is_workflow_changing_comment(body: str) -> bool:
|
||||
"""True when comment text implies a workflow/state transition."""
|
||||
text = (body or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if _is_machine_generated(text):
|
||||
return False
|
||||
if _has_canonical_heading(text):
|
||||
return True
|
||||
if _WORKFLOW_TRIGGERS.search(text):
|
||||
return True
|
||||
fields = _parse_fields(text)
|
||||
if "STATE" in fields or "WHO_IS_NEXT" in fields:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def infer_comment_context(body: str, *, explicit: str | None = None) -> str:
|
||||
if explicit:
|
||||
return explicit
|
||||
text = body or ""
|
||||
if "## Canonical Discussion Summary" in text:
|
||||
return "discussion_summary"
|
||||
if "## Canonical PR State" in text:
|
||||
return "pr_comment"
|
||||
if "## Canonical Issue State" in text:
|
||||
return "issue_comment"
|
||||
fields = _parse_fields(text)
|
||||
if fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_ITEM"):
|
||||
return "supersession"
|
||||
if any(k in fields for k in ("HEAD_SHA", "REVIEW_STATUS", "MERGE_READY", "ISSUE")):
|
||||
return "pr_comment"
|
||||
return "issue_comment"
|
||||
|
||||
|
||||
def _required_fields_for_context(context: str) -> tuple[str, ...]:
|
||||
if context in ("pr_comment", "pr_review"):
|
||||
return _PR_REQUIRED
|
||||
if context == "supersession":
|
||||
return _SUPERSESSION_REQUIRED
|
||||
if context == "discussion_summary":
|
||||
return _BASE_REQUIRED + ("DECISION", "SUBSTANTIVE_COMMENTS")
|
||||
return _ISSUE_REQUIRED
|
||||
|
||||
|
||||
def _is_vague_next_action(value: str) -> bool:
|
||||
normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
|
||||
normalized = normalized.rstrip(".")
|
||||
if not normalized:
|
||||
return True
|
||||
if normalized in _VAGUE_NEXT_ACTIONS:
|
||||
return True
|
||||
if len(normalized) < 12:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _next_prompt_ok(value: str) -> bool:
|
||||
text = (value or "").strip()
|
||||
if len(text) < 40:
|
||||
return False
|
||||
if text.lower() in {"n/a", "none", "tbd", "todo"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _state_value(fields: dict[str, str]) -> str:
|
||||
return (fields.get("STATE") or "").strip().lower()
|
||||
|
||||
|
||||
def _suggested_template(context: str) -> str:
|
||||
if context == "pr_comment" or context == "pr_review":
|
||||
return (
|
||||
"## Canonical PR State\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"```text\n<paste-ready prompt>\n```\n"
|
||||
"WHAT_HAPPENED:\n"
|
||||
"WHY:\n"
|
||||
"ISSUE:\n"
|
||||
"HEAD_SHA:\n"
|
||||
"REVIEW_STATUS:\n"
|
||||
"MERGE_READY:\n"
|
||||
"BLOCKERS:\n"
|
||||
"VALIDATION:\n"
|
||||
"LAST_UPDATED_BY:\n"
|
||||
)
|
||||
if context == "supersession":
|
||||
return (
|
||||
"## Canonical PR State\n\n"
|
||||
"STATE:\nsuperseded\n"
|
||||
"WHO_IS_NEXT:\nreconciler\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"WHY:\n"
|
||||
"CANONICAL_ITEM:\n"
|
||||
"SUPERSEDED_ITEM:\n"
|
||||
"CLOSE_OR_KEEP_OPEN:\n"
|
||||
)
|
||||
if context == "discussion_summary":
|
||||
return (
|
||||
"## Canonical Discussion Summary\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"DECISION:\n"
|
||||
"WHY:\n"
|
||||
"SUBSTANTIVE_COMMENTS:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
)
|
||||
return (
|
||||
"## Canonical Issue State\n\n"
|
||||
"STATE:\n"
|
||||
"WHO_IS_NEXT:\n"
|
||||
"NEXT_ACTION:\n"
|
||||
"NEXT_PROMPT:\n"
|
||||
"```text\n<paste-ready prompt>\n```\n"
|
||||
"WHAT_HAPPENED:\n"
|
||||
"WHY:\n"
|
||||
"RELATED_PRS:\n"
|
||||
"BLOCKERS:\n"
|
||||
"VALIDATION:\n"
|
||||
"LAST_UPDATED_BY:\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_correction_message(
|
||||
*,
|
||||
missing_fields: list[str],
|
||||
vague_fields: list[str],
|
||||
extra_reasons: list[str],
|
||||
context: str,
|
||||
) -> str:
|
||||
parts = [
|
||||
"Canonical comment validation failed (fail closed before posting).",
|
||||
]
|
||||
if missing_fields:
|
||||
parts.append("Missing fields: " + ", ".join(missing_fields) + ".")
|
||||
if vague_fields:
|
||||
parts.append("Vague or invalid fields: " + ", ".join(vague_fields) + ".")
|
||||
parts.extend(extra_reasons)
|
||||
parts.append("Fill the suggested template and retry.")
|
||||
parts.append("Suggested template:\n" + _suggested_template(context))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def assess_canonical_comment(
|
||||
body: str,
|
||||
*,
|
||||
context: str | None = None,
|
||||
force_workflow: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate outgoing comment text before a Gitea mutation."""
|
||||
text = (body or "").strip()
|
||||
ctx = infer_comment_context(text, explicit=context)
|
||||
|
||||
if not text:
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
if _is_machine_generated(text):
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
workflow = force_workflow or is_workflow_changing_comment(text)
|
||||
if not workflow:
|
||||
return {
|
||||
"allowed": True,
|
||||
"is_workflow_comment": False,
|
||||
"context": ctx,
|
||||
"missing_fields": [],
|
||||
"vague_fields": [],
|
||||
"correction_message": "",
|
||||
"suggested_template": "",
|
||||
}
|
||||
|
||||
fields = _parse_fields(text)
|
||||
required = _required_fields_for_context(ctx)
|
||||
missing = [name for name in required if not (fields.get(name) or "").strip()]
|
||||
|
||||
vague: list[str] = []
|
||||
extra: list[str] = []
|
||||
|
||||
who = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if who and who not in VALID_ROLES:
|
||||
vague.append("WHO_IS_NEXT")
|
||||
extra.append(
|
||||
f"WHO_IS_NEXT must be one of: {', '.join(sorted(VALID_ROLES))}."
|
||||
)
|
||||
|
||||
next_action = fields.get("NEXT_ACTION") or ""
|
||||
if next_action and _is_vague_next_action(next_action):
|
||||
vague.append("NEXT_ACTION")
|
||||
|
||||
next_prompt = fields.get("NEXT_PROMPT") or ""
|
||||
if not _next_prompt_ok(next_prompt):
|
||||
if "NEXT_PROMPT" not in missing:
|
||||
vague.append("NEXT_PROMPT")
|
||||
|
||||
state = _state_value(fields)
|
||||
blockers = (fields.get("BLOCKERS") or "").strip()
|
||||
if "blocked" in state:
|
||||
if not blockers or blockers.lower() in {"none", "n/a"}:
|
||||
missing.append("BLOCKERS (unblock condition)")
|
||||
elif not _UNBLOCK_RE.search(blockers):
|
||||
vague.append("BLOCKERS")
|
||||
extra.append("BLOCKED state requires an explicit unblock condition in BLOCKERS.")
|
||||
|
||||
if "superseded" in state:
|
||||
canon = (fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY") or "").strip()
|
||||
superseded = (fields.get("SUPERSEDED_ITEM") or fields.get("SUPERSEDES") or "").strip()
|
||||
if not canon and "CANONICAL_ITEM" not in missing:
|
||||
missing.append("CANONICAL_ITEM")
|
||||
if not superseded and "SUPERSEDED_ITEM" not in missing:
|
||||
missing.append("SUPERSEDED_ITEM")
|
||||
|
||||
if "ready-to-merge" in state or "ready to merge" in state:
|
||||
head_sha = fields.get("HEAD_SHA") or ""
|
||||
merge_ready = fields.get("MERGE_READY") or ""
|
||||
review_status = fields.get("REVIEW_STATUS") or ""
|
||||
validation = fields.get("VALIDATION") or ""
|
||||
proof_blob = " ".join((head_sha, merge_ready, review_status, validation))
|
||||
has_sha = bool(_FULL_SHA_RE.search(proof_blob) or _SHORT_SHA_RE.search(proof_blob))
|
||||
has_approval = bool(_APPROVAL_PROOF_RE.search(proof_blob))
|
||||
if not has_sha or not has_approval:
|
||||
extra.append(
|
||||
"ready-to-merge STATE requires approval proof and HEAD_SHA in "
|
||||
"REVIEW_STATUS, MERGE_READY, HEAD_SHA, or VALIDATION."
|
||||
)
|
||||
|
||||
if ctx in ("pr_comment", "pr_review"):
|
||||
head_sha = (fields.get("HEAD_SHA") or "").strip()
|
||||
if not head_sha or not _SHORT_SHA_RE.search(head_sha):
|
||||
if "HEAD_SHA" not in missing:
|
||||
missing.append("HEAD_SHA")
|
||||
|
||||
if ctx == "issue_comment" and _PR_REF_RE.search(text):
|
||||
related = (fields.get("RELATED_PRS") or "").strip()
|
||||
if not related or related.lower() in {"none", "n/a", "-"}:
|
||||
missing.append("RELATED_PRS")
|
||||
|
||||
# #695 AC7: untrusted / offline approval claims cannot certify merger handoff.
|
||||
try:
|
||||
import review_quarantine as _rq
|
||||
|
||||
for claim_reason in _rq.assess_untrusted_canonical_approval_claim(text):
|
||||
extra.append(claim_reason)
|
||||
except Exception:
|
||||
# Fail closed on import/runtime errors for approval-shaped claims only.
|
||||
lower = text.lower()
|
||||
if (
|
||||
"state:\napproved" in lower
|
||||
or "who_is_next:\nmerger" in lower
|
||||
or "merge_ready: true" in lower
|
||||
or "merge_ready:\ntrue" in lower
|
||||
or "ready-to-merge" in lower
|
||||
):
|
||||
extra.append(
|
||||
"canonical approval/merge-ready claim could not be verified "
|
||||
"for native review proof (#695 AC7; fail closed)"
|
||||
)
|
||||
|
||||
allowed = not missing and not vague and not extra
|
||||
correction = ""
|
||||
if not allowed:
|
||||
correction = _build_correction_message(
|
||||
missing_fields=missing,
|
||||
vague_fields=vague,
|
||||
extra_reasons=extra,
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"is_workflow_comment": True,
|
||||
"context": ctx,
|
||||
"missing_fields": missing,
|
||||
"vague_fields": vague,
|
||||
"extra_reasons": extra,
|
||||
"correction_message": correction,
|
||||
"suggested_template": _suggested_template(ctx) if not allowed else "",
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Canonical state comment validation helpers (#495).
|
||||
|
||||
These helpers validate durable issue/PR/discussion state handoff comments.
|
||||
They are intentionally pure and do not post comments; MCP mutation hooks are
|
||||
owned by #496.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CANONICAL_HEADINGS = (
|
||||
"canonical issue state",
|
||||
"canonical pr state",
|
||||
"canonical discussion summary",
|
||||
)
|
||||
|
||||
REQUIRED_FIELDS = ("STATE", "WHO_IS_NEXT", "NEXT_ACTION", "NEXT_PROMPT")
|
||||
ALLOWED_NEXT_ACTORS = {
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
}
|
||||
|
||||
VAGUE_NEXT_ACTIONS = {
|
||||
"continue",
|
||||
"handle this",
|
||||
"do it",
|
||||
"fix it",
|
||||
"proceed",
|
||||
"follow up",
|
||||
"next",
|
||||
"tbd",
|
||||
"todo",
|
||||
"n/a",
|
||||
"none",
|
||||
}
|
||||
|
||||
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_CLAIMS_STATE_UPDATE_RE = re.compile(
|
||||
r"canonical\s+(?:issue|pr|discussion)?\s*state|"
|
||||
r"state\s+comment\s+(?:posted|created|updated)|"
|
||||
r"next[- ]action\s+comment\s+(?:posted|created|updated)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_state_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a canonical state block."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
for line in (text or "").splitlines():
|
||||
match = _FIELD_RE.match(line)
|
||||
if match:
|
||||
current_key = match.group(1).strip().upper().replace(" ", "_")
|
||||
fields[current_key] = match.group(2).strip()
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if current_key and stripped and not stripped.startswith("#"):
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_canonical_state_block(text: str | None) -> bool:
|
||||
lower = (text or "").lower()
|
||||
return any(heading in lower for heading in CANONICAL_HEADINGS)
|
||||
|
||||
|
||||
def claims_canonical_state_update(text: str | None) -> bool:
|
||||
"""Return True when text claims a canonical state/next-action update."""
|
||||
return bool(_CLAIMS_STATE_UPDATE_RE.search(text or ""))
|
||||
|
||||
|
||||
def _empty_or_placeholder(value: str | None) -> bool:
|
||||
value = (value or "").strip().lower()
|
||||
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>"}
|
||||
|
||||
|
||||
def _vague_next_action(value: str | None) -> bool:
|
||||
normalized = re.sub(r"\s+", " ", (value or "").strip().lower())
|
||||
return normalized in VAGUE_NEXT_ACTIONS
|
||||
|
||||
|
||||
def validate_canonical_state_comment(text: str | None) -> dict:
|
||||
"""Validate a canonical state comment or embedded final-report block.
|
||||
|
||||
Returns a dict with ``valid`` and ``reasons``. The validator focuses on
|
||||
fields that make continuation possible: current state, next actor, next
|
||||
action, and paste-ready next prompt, plus a few contradiction checks.
|
||||
"""
|
||||
fields = extract_state_fields(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required canonical state field: {field}")
|
||||
|
||||
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if actor and actor not in ALLOWED_NEXT_ACTORS:
|
||||
reasons.append(
|
||||
"WHO_IS_NEXT must be one of: "
|
||||
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
|
||||
)
|
||||
|
||||
if _vague_next_action(fields.get("NEXT_ACTION")):
|
||||
reasons.append("NEXT_ACTION is too vague for durable continuation")
|
||||
|
||||
state = (fields.get("STATE") or "").strip().lower().replace("-", "_")
|
||||
if "ready_to_merge" in state or (
|
||||
state == "approved" and "pr" in (text or "").lower()
|
||||
):
|
||||
review_status = (fields.get("REVIEW_STATUS") or "").lower()
|
||||
head_sha = fields.get("HEAD_SHA") or ""
|
||||
merge_ready = (fields.get("MERGE_READY") or "").lower()
|
||||
if "approved" not in review_status:
|
||||
reasons.append("ready-to-merge state requires approved REVIEW_STATUS")
|
||||
if not _FULL_SHA_RE.search(head_sha):
|
||||
reasons.append("ready-to-merge state requires full HEAD_SHA proof")
|
||||
if merge_ready and not merge_ready.startswith(("yes", "true")):
|
||||
reasons.append("ready-to-merge state contradicts MERGE_READY")
|
||||
|
||||
if "superseded" in state:
|
||||
canonical = fields.get("CANONICAL_ITEM") or fields.get("SUPERSEDED_BY")
|
||||
if _empty_or_placeholder(canonical):
|
||||
reasons.append("superseded state requires canonical item proof")
|
||||
|
||||
if "blocked" in state:
|
||||
blockers = fields.get("BLOCKERS") or ""
|
||||
if _empty_or_placeholder(blockers):
|
||||
reasons.append("blocked state requires BLOCKERS/unblock condition")
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def validate_final_report_state_update(report_text: str | None) -> dict:
|
||||
"""Validate canonical state-update claims inside a final report."""
|
||||
text = report_text or ""
|
||||
if not claims_canonical_state_update(text) and not contains_canonical_state_block(text):
|
||||
return {
|
||||
"applicable": False,
|
||||
"valid": True,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
if not contains_canonical_state_block(text):
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": False,
|
||||
"reasons": [
|
||||
"final report claims a canonical state update but includes no canonical state block"
|
||||
],
|
||||
}
|
||||
|
||||
result = validate_canonical_state_comment(text)
|
||||
return {
|
||||
"applicable": True,
|
||||
"valid": result["valid"],
|
||||
"fields": result["fields"],
|
||||
"reasons": result["reasons"],
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Canonical Thread Handoff (CTH) protocol for issue and PR comments (#505).
|
||||
|
||||
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
|
||||
thread. It records current state, decisions, blockers, proof, and the exact
|
||||
next prompt/action for the next LLM or person.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- cth:v1 -->"
|
||||
|
||||
CTH_TERM = "Canonical Thread Handoff"
|
||||
CTH_ABBREV = "CTH"
|
||||
|
||||
CTH_TYPES = frozenset({
|
||||
"State Handoff",
|
||||
"Controller Decision",
|
||||
"Author Handoff",
|
||||
"Reviewer Handoff",
|
||||
"Merger Handoff",
|
||||
"Supersession Notice",
|
||||
"Blocker",
|
||||
})
|
||||
|
||||
_REQUIRED_BASE_FIELDS = (
|
||||
"status",
|
||||
"next owner",
|
||||
"current blocker",
|
||||
"decision",
|
||||
"proof",
|
||||
"next action",
|
||||
"ready-to-paste prompt",
|
||||
)
|
||||
|
||||
_HEADING_RE = re.compile(
|
||||
r"^##\s+CTH:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FIELD_RE = re.compile(
|
||||
r"^([A-Za-z][A-Za-z0-9 /-]*):\s*(.+?)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def format_cth_body(
|
||||
*,
|
||||
cth_type: str,
|
||||
status: str,
|
||||
next_owner: str,
|
||||
current_blocker: str = "none",
|
||||
decision: str = "none",
|
||||
proof: str = "none",
|
||||
next_action: str = "none",
|
||||
ready_to_paste_prompt: str = "none",
|
||||
extra_fields: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Render a canonical CTH comment body."""
|
||||
normalized_type = (cth_type or "").strip()
|
||||
if normalized_type not in CTH_TYPES:
|
||||
raise ValueError(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
lines = [
|
||||
MARKER,
|
||||
f"## CTH: {normalized_type}",
|
||||
"",
|
||||
f"Status: {(status or 'unknown').strip() or 'unknown'}",
|
||||
f"Next owner: {(next_owner or 'unknown').strip() or 'unknown'}",
|
||||
f"Current blocker: {(current_blocker or 'none').strip() or 'none'}",
|
||||
f"Decision: {(decision or 'none').strip() or 'none'}",
|
||||
f"Proof: {(proof or 'none').strip() or 'none'}",
|
||||
f"Next action: {(next_action or 'none').strip() or 'none'}",
|
||||
f"Ready-to-paste prompt: {(ready_to_paste_prompt or 'none').strip() or 'none'}",
|
||||
]
|
||||
for key, value in (extra_fields or {}).items():
|
||||
label = (key or "").strip()
|
||||
if not label:
|
||||
continue
|
||||
lines.append(f"{label}: {(value or 'none').strip() or 'none'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_cth_comment(body: str) -> dict[str, Any] | None:
|
||||
"""Parse one CTH comment body, or None when not a CTH comment."""
|
||||
text = body or ""
|
||||
if MARKER not in text and not _HEADING_RE.search(text):
|
||||
return None
|
||||
heading = _HEADING_RE.search(text)
|
||||
if not heading:
|
||||
return None
|
||||
cth_type = heading.group(1).strip()
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
key = match.group(1).strip().lower()
|
||||
if key.startswith("cth"):
|
||||
continue
|
||||
fields[key] = match.group(2).strip()
|
||||
return {
|
||||
"cth_type": cth_type,
|
||||
"fields": fields,
|
||||
"raw_body": text,
|
||||
}
|
||||
|
||||
|
||||
def assess_cth_comment(body: str) -> dict[str, Any]:
|
||||
"""Validate a CTH comment has required base fields and a known type."""
|
||||
parsed = parse_cth_comment(body)
|
||||
reasons: list[str] = []
|
||||
if not parsed:
|
||||
return {
|
||||
"valid": False,
|
||||
"block": True,
|
||||
"reasons": ["comment is not a Canonical Thread Handoff (CTH)"],
|
||||
"parsed": None,
|
||||
}
|
||||
|
||||
cth_type = parsed.get("cth_type") or ""
|
||||
if cth_type not in CTH_TYPES:
|
||||
reasons.append(
|
||||
f"unknown CTH type '{cth_type}'; expected one of {sorted(CTH_TYPES)}"
|
||||
)
|
||||
|
||||
fields = parsed.get("fields") or {}
|
||||
missing = [name for name in _REQUIRED_BASE_FIELDS if not (fields.get(name) or "").strip()]
|
||||
if missing:
|
||||
reasons.append(
|
||||
"CTH missing required fields: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
vague_prompt = (fields.get("ready-to-paste prompt") or "").strip().lower()
|
||||
if vague_prompt in {"", "none", "tbd", "n/a", "todo"}:
|
||||
reasons.append("ready-to-paste prompt must be concrete and paste-ready")
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"parsed": parsed,
|
||||
"cth_type": cth_type,
|
||||
}
|
||||
|
||||
|
||||
def find_latest_cth(comments: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Return the newest valid CTH comment from a Gitea comment list."""
|
||||
latest: dict[str, Any] | None = None
|
||||
latest_ts: datetime | None = None
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
parsed = parse_cth_comment(body)
|
||||
if not parsed:
|
||||
continue
|
||||
created = _parse_timestamp(comment.get("created_at"))
|
||||
if latest is None or (created and (latest_ts is None or created >= latest_ts)):
|
||||
latest = {
|
||||
"comment_id": comment.get("id"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"author": (comment.get("user") or {}).get("login"),
|
||||
**parsed,
|
||||
}
|
||||
latest_ts = created
|
||||
return latest
|
||||
|
||||
|
||||
def assess_cth_supersedes_non_cth(
|
||||
*,
|
||||
latest_cth: dict[str, Any] | None,
|
||||
narrative_claim: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when narrative relies on stale non-CTH state despite newer CTH."""
|
||||
if not latest_cth:
|
||||
return {"block": False, "reasons": []}
|
||||
text = (narrative_claim or "").strip()
|
||||
if not text:
|
||||
return {"block": False, "reasons": []}
|
||||
if parse_cth_comment(text):
|
||||
return {"block": False, "reasons": []}
|
||||
return {
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"workflow narrative must treat the latest CTH comment as authoritative; "
|
||||
f"found newer CTH type '{latest_cth.get('cth_type')}' "
|
||||
f"(comment #{latest_cth.get('comment_id')})"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
EXAMPLE_SCENARIOS = (
|
||||
"pr_approved_ready_for_merger",
|
||||
"pr_request_changes_to_author",
|
||||
"duplicate_superseded_pr_closure",
|
||||
"blocked_dirty_root_worktree",
|
||||
"stale_head_requires_fresh_review",
|
||||
"issue_implementation_handoff",
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Hard-stop terminal mode after reviewer capability denial (#197)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
TERMINAL_REPORT_HEADING = (
|
||||
"Cannot perform reviewer task under current profile. "
|
||||
"No reviewer mutations performed."
|
||||
)
|
||||
|
||||
REVIEWER_CAPABILITY_TASKS = frozenset({
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"request_changes_pr",
|
||||
"approve_pr",
|
||||
})
|
||||
|
||||
BLOCKED_QUEUE_TOOLS = frozenset({
|
||||
"list_prs",
|
||||
"check_pr_eligibility",
|
||||
"view_pr",
|
||||
"submit_pr_review",
|
||||
"dry_run_pr_review",
|
||||
"merge_pr",
|
||||
"review_pr",
|
||||
})
|
||||
|
||||
_session_terminal: dict | None = None
|
||||
|
||||
|
||||
def enter_from_capability_result(capability: dict) -> dict | None:
|
||||
"""Enter terminal mode when a reviewer/merge task is denied."""
|
||||
global _session_terminal
|
||||
task = (capability or {}).get("requested_task", "")
|
||||
required_role = (capability or {}).get("required_role_kind")
|
||||
if not capability.get("stop_required"):
|
||||
return None
|
||||
if required_role != "reviewer" and task not in REVIEWER_CAPABILITY_TASKS:
|
||||
return None
|
||||
record = {
|
||||
"active": True,
|
||||
"requested_task": task,
|
||||
"required_role_kind": required_role,
|
||||
"active_profile": capability.get("active_profile"),
|
||||
"active_identity": capability.get("active_identity"),
|
||||
"stop_required": True,
|
||||
"exact_safe_next_action": capability.get("exact_safe_next_action"),
|
||||
"terminal_message": TERMINAL_REPORT_HEADING,
|
||||
}
|
||||
_session_terminal = record
|
||||
return dict(record)
|
||||
|
||||
|
||||
def _is_reviewer_denial(capability: dict) -> bool:
|
||||
task = (capability or {}).get("requested_task", "")
|
||||
required_role = (capability or {}).get("required_role_kind")
|
||||
return (
|
||||
required_role == "reviewer"
|
||||
or task in REVIEWER_CAPABILITY_TASKS
|
||||
)
|
||||
|
||||
|
||||
def sync_from_capability_result(capability: dict) -> dict | None:
|
||||
"""Enter or clear terminal mode from a capability resolution (#238).
|
||||
|
||||
Reviewer denials activate terminal mode for the denied operation only.
|
||||
A later allowed task route clears stale denial state so author read-only
|
||||
tools (e.g. ``list_prs``) are not permanently blocked.
|
||||
"""
|
||||
if (capability or {}).get("stop_required") and _is_reviewer_denial(capability):
|
||||
return enter_from_capability_result(capability)
|
||||
clear()
|
||||
return None
|
||||
|
||||
|
||||
def enter_from_route_result(route: dict) -> dict | None:
|
||||
"""Enter terminal mode from a role router wrong_role_stop (#206 compat)."""
|
||||
if (route or {}).get("route_result") != "wrong_role_stop":
|
||||
return None
|
||||
if route.get("required_role") != "reviewer":
|
||||
return None
|
||||
return enter_from_capability_result({
|
||||
"requested_task": route.get("task_type"),
|
||||
"required_role_kind": "reviewer",
|
||||
"stop_required": True,
|
||||
"active_profile": route.get("active_profile"),
|
||||
"active_identity": None,
|
||||
"exact_safe_next_action": route.get("message"),
|
||||
})
|
||||
|
||||
|
||||
def is_active() -> bool:
|
||||
return bool(_session_terminal and _session_terminal.get("active"))
|
||||
|
||||
|
||||
def active_record() -> dict | None:
|
||||
if not is_active():
|
||||
return None
|
||||
return dict(_session_terminal)
|
||||
|
||||
|
||||
def clear():
|
||||
global _session_terminal
|
||||
_session_terminal = None
|
||||
|
||||
|
||||
def check_reviewer_queue_tool(tool_name: str) -> tuple[bool, list[str]]:
|
||||
"""Return (allowed, reasons). False when terminal mode blocks queue work."""
|
||||
if not is_active():
|
||||
return True, []
|
||||
name = (tool_name or "").strip().lower().removeprefix("gitea_")
|
||||
if name in BLOCKED_QUEUE_TOOLS:
|
||||
denied_task = (_session_terminal or {}).get("requested_task") or "unknown"
|
||||
return False, [
|
||||
TERMINAL_REPORT_HEADING,
|
||||
f"Reviewer queue tool '{tool_name}' is blocked by the current "
|
||||
f"capability denial for task '{denied_task}' (fail closed).",
|
||||
"Resolve or route an allowed author task to clear stale denial "
|
||||
"state, or relaunch a reviewer MCP namespace for reviewer work.",
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
def validate_eligibility_wording(text: str) -> tuple[bool, list[str]]:
|
||||
"""Reject session-based eligibility reasoning (#197)."""
|
||||
lower = (text or "").lower()
|
||||
violations = []
|
||||
if "not authored by this session" in lower:
|
||||
violations.append(
|
||||
"eligibility must use authenticated account identity, not "
|
||||
"'this session' wording"
|
||||
)
|
||||
if re.search(r"not (?:self-)?authored by (?:the )?session", lower):
|
||||
violations.append("session-based eligibility reasoning is invalid")
|
||||
return (len(violations) == 0), violations
|
||||
|
||||
|
||||
def assess_capability_stop_report(
|
||||
report_text: str,
|
||||
*,
|
||||
trust_gate_status: str | None = None,
|
||||
capability_denied: bool = True,
|
||||
) -> dict:
|
||||
"""Validate final report purity after reviewer capability denial."""
|
||||
text = report_text or ""
|
||||
lower = text.lower()
|
||||
violations = []
|
||||
|
||||
if capability_denied and TERMINAL_REPORT_HEADING.lower() not in lower:
|
||||
violations.append("missing required terminal report heading")
|
||||
|
||||
forbidden_patterns = [
|
||||
("pr selection", re.compile(
|
||||
r"selected pr|pr #\d+ (?:to review|selected)|eligible pr|"
|
||||
r"next pr to review", re.I)),
|
||||
("sibling repo inventory", re.compile(
|
||||
r"sibling repo|other repo|mcp-control-plane|gitea-tools and", re.I)),
|
||||
("author fallback", re.compile(
|
||||
r"rebase conflicted|author-side fallback|have me rebase|"
|
||||
r"implement the fix|push a branch|open a pr for", re.I)),
|
||||
("invalid session eligibility", re.compile(
|
||||
r"not authored by this session", re.I)),
|
||||
]
|
||||
for label, pattern in forbidden_patterns:
|
||||
if pattern.search(text):
|
||||
violations.append(f"forbidden after hard stop: {label}")
|
||||
|
||||
empty_queue_patterns = re.compile(
|
||||
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|"
|
||||
r"inventory empty",
|
||||
re.I,
|
||||
)
|
||||
parsed_status = None
|
||||
for line in text.splitlines():
|
||||
if "pr_inventory_trust_gate.status:" in line.lower():
|
||||
parsed_status = line.split(":", 1)[1].strip()
|
||||
break
|
||||
effective_status = trust_gate_status or parsed_status
|
||||
if empty_queue_patterns.search(text):
|
||||
if effective_status != "trusted_empty":
|
||||
violations.append(
|
||||
"empty-queue claim after capability stop without "
|
||||
"pr_inventory_trust_gate.status == trusted_empty"
|
||||
)
|
||||
|
||||
ok, elig_violations = validate_eligibility_wording(text)
|
||||
violations.extend(elig_violations)
|
||||
|
||||
if violations:
|
||||
return {
|
||||
"pure": False,
|
||||
"downgraded": True,
|
||||
"violations": violations,
|
||||
"reasons": violations,
|
||||
}
|
||||
return {
|
||||
"pure": True,
|
||||
"downgraded": False,
|
||||
"violations": [],
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
def build_terminal_report(capability: dict) -> dict:
|
||||
"""Minimal allowed report fields after hard stop."""
|
||||
return {
|
||||
"terminal_mode": True,
|
||||
"heading": TERMINAL_REPORT_HEADING,
|
||||
"authenticated_profile": capability.get("active_profile"),
|
||||
"authenticated_identity": capability.get("active_identity"),
|
||||
"denied_task": capability.get("requested_task"),
|
||||
"required_role_kind": capability.get("required_role_kind"),
|
||||
"stop_required": capability.get("stop_required"),
|
||||
"required_action": capability.get("exact_safe_next_action"),
|
||||
"mutations_performed": False,
|
||||
"allowed_sections": [
|
||||
"authenticated identity/profile",
|
||||
"denied capability result",
|
||||
"reason task cannot proceed",
|
||||
"required reviewer profile/identity",
|
||||
"mutation confirmation (none)",
|
||||
],
|
||||
"forbidden_sections": [
|
||||
"PR selection",
|
||||
"sibling-repo queue recommendations",
|
||||
"author-side fallback suggestions",
|
||||
"empty-queue claims without trusted_empty",
|
||||
"session-based eligibility wording",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Live PR head re-pin before conflict-fix classification (#522).
|
||||
|
||||
Open PR inventory fields (mergeable, head_sha) can be stale. Author sessions
|
||||
must re-fetch live PR state and pin the live head before classifying a PR as
|
||||
conflicted or creating a conflict-fix worktree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_INVENTORY_HEAD_RE = re.compile(
|
||||
r"(?:inventory head sha|stale inventory head sha)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_RE = re.compile(
|
||||
r"(?:live head sha|pinned head sha|live pr head sha)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPINS_TOOL_RE = re.compile(
|
||||
r"gitea_assess_conflict_fix_classification",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLASSIFICATION_RE = re.compile(
|
||||
r"conflict[- ]fix classification\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP = "stale_inventory_skip"
|
||||
CLASSIFICATION_CONFLICT_FIX_NEEDED = "conflict_fix_needed"
|
||||
CLASSIFICATION_LIVE_MERGEABLE = "live_mergeable_skip"
|
||||
CLASSIFICATION_INCOMPLETE = "incomplete_live_repin"
|
||||
|
||||
_VALID_CLASSIFICATIONS = frozenset({
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP,
|
||||
CLASSIFICATION_CONFLICT_FIX_NEEDED,
|
||||
CLASSIFICATION_LIVE_MERGEABLE,
|
||||
CLASSIFICATION_INCOMPLETE,
|
||||
})
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text if _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def assess_conflict_fix_classification(
|
||||
*,
|
||||
pr_number: int,
|
||||
inventory_head_sha: str | None = None,
|
||||
inventory_mergeable: bool | None = None,
|
||||
live_head_sha: str | None = None,
|
||||
live_mergeable: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify whether conflict-fix work is justified from live PR state.
|
||||
|
||||
Inventory fields are advisory only. Live head + live mergeable are required
|
||||
before any conflict-fix worktree may be created.
|
||||
"""
|
||||
inv_head = _normalize_sha(inventory_head_sha)
|
||||
live_head = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
|
||||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||
return {
|
||||
"classification": CLASSIFICATION_INCOMPLETE,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": False,
|
||||
"inventory_stale_mergeable": False,
|
||||
"pinned_head_sha": None,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": ["pr_number must be a positive integer (fail closed)"],
|
||||
}
|
||||
|
||||
if live_head is None:
|
||||
reasons.append(
|
||||
"live PR head SHA missing or not a full 40-char hex SHA; "
|
||||
"re-fetch the PR before conflict-fix classification (fail closed)"
|
||||
)
|
||||
if live_mergeable is None:
|
||||
reasons.append(
|
||||
"live PR mergeable value missing; re-fetch the PR before "
|
||||
"conflict-fix classification (fail closed)"
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"classification": CLASSIFICATION_INCOMPLETE,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": bool(
|
||||
inv_head and live_head and inv_head != live_head
|
||||
),
|
||||
"inventory_stale_mergeable": (
|
||||
inventory_mergeable is not None
|
||||
and live_mergeable is not None
|
||||
and bool(inventory_mergeable) != bool(live_mergeable)
|
||||
),
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
inventory_stale_head = bool(inv_head and inv_head != live_head)
|
||||
inventory_stale_mergeable = (
|
||||
inventory_mergeable is not None
|
||||
and bool(inventory_mergeable) != bool(live_mergeable)
|
||||
)
|
||||
|
||||
# Live mergeable wins: do not start conflict-fix work.
|
||||
if live_mergeable is True:
|
||||
classification = (
|
||||
CLASSIFICATION_STALE_INVENTORY_SKIP
|
||||
if (
|
||||
inventory_mergeable is False
|
||||
or inventory_stale_head
|
||||
or inventory_stale_mergeable
|
||||
)
|
||||
else CLASSIFICATION_LIVE_MERGEABLE
|
||||
)
|
||||
note = []
|
||||
if inventory_stale_head:
|
||||
note.append(
|
||||
f"inventory head {inv_head} differs from live head {live_head}; "
|
||||
"use live head only"
|
||||
)
|
||||
if inventory_mergeable is False:
|
||||
note.append(
|
||||
"inventory reported mergeable:false but live PR is mergeable:true; "
|
||||
"skip author conflict-fix mutation"
|
||||
)
|
||||
return {
|
||||
"classification": classification,
|
||||
"worktree_allowed": False,
|
||||
"skip_author_mutation": True,
|
||||
"inventory_stale_head": inventory_stale_head,
|
||||
"inventory_stale_mergeable": inventory_stale_mergeable,
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": note,
|
||||
}
|
||||
|
||||
# live_mergeable is False → conflict-fix may proceed on the pinned live head.
|
||||
note = []
|
||||
if inventory_stale_head:
|
||||
note.append(
|
||||
f"inventory head {inv_head} differs from live head {live_head}; "
|
||||
"pin and use live head only for conflict-fix work"
|
||||
)
|
||||
if inventory_mergeable is True:
|
||||
note.append(
|
||||
"inventory reported mergeable:true but live PR is mergeable:false; "
|
||||
"trust live mergeable and proceed only with live head pin"
|
||||
)
|
||||
note.append(
|
||||
f"live PR #{pr_number} is mergeable:false at pinned head {live_head}; "
|
||||
"conflict-fix worktree allowed for that head only"
|
||||
)
|
||||
return {
|
||||
"classification": CLASSIFICATION_CONFLICT_FIX_NEEDED,
|
||||
"worktree_allowed": True,
|
||||
"skip_author_mutation": False,
|
||||
"inventory_stale_head": inventory_stale_head,
|
||||
"inventory_stale_mergeable": inventory_stale_mergeable,
|
||||
"pinned_head_sha": live_head,
|
||||
"inventory_head_sha": inv_head,
|
||||
"live_head_sha": live_head,
|
||||
"inventory_mergeable": inventory_mergeable,
|
||||
"live_mergeable": live_mergeable,
|
||||
"pr_number": pr_number,
|
||||
"reasons": note,
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_classification_final_report(
|
||||
report_text: str,
|
||||
**_kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Require live-head re-pin proof when a report claims conflict-fix work (#522)."""
|
||||
text = report_text or ""
|
||||
lower = text.lower()
|
||||
mentions_conflict_fix = (
|
||||
"conflict-fix" in lower
|
||||
or "conflict fix" in lower
|
||||
or "conflict_fix" in lower
|
||||
)
|
||||
if not mentions_conflict_fix:
|
||||
return {"proven": True, "reasons": [], "applicable": False}
|
||||
|
||||
reasons: list[str] = []
|
||||
if not _REPINS_TOOL_RE.search(text):
|
||||
reasons.append(
|
||||
"conflict-fix report must cite gitea_assess_conflict_fix_classification "
|
||||
"(live head re-pin tool)"
|
||||
)
|
||||
|
||||
live_match = _LIVE_HEAD_RE.search(text)
|
||||
if not live_match:
|
||||
reasons.append(
|
||||
"conflict-fix report must state Live head SHA: <40-char hex> "
|
||||
"(or Pinned head SHA / Live PR head SHA)"
|
||||
)
|
||||
else:
|
||||
live_sha = _normalize_sha(live_match.group(1))
|
||||
if live_sha is None:
|
||||
reasons.append("live head SHA is not a full 40-char hex digest")
|
||||
|
||||
inv_match = _INVENTORY_HEAD_RE.search(text)
|
||||
# Inventory head is optional but recommended when classification is stale skip.
|
||||
class_match = _CLASSIFICATION_RE.search(text)
|
||||
if not class_match:
|
||||
reasons.append(
|
||||
"conflict-fix report must state Conflict-fix classification: "
|
||||
f"<{'|'.join(sorted(_VALID_CLASSIFICATIONS))}>"
|
||||
)
|
||||
else:
|
||||
classification = class_match.group(1).strip().lower().replace(" ", "_")
|
||||
# allow hyphenated forms
|
||||
classification = classification.replace("-", "_")
|
||||
if classification not in _VALID_CLASSIFICATIONS:
|
||||
reasons.append(
|
||||
f"unknown conflict-fix classification {class_match.group(1)!r}; "
|
||||
f"expected one of {sorted(_VALID_CLASSIFICATIONS)}"
|
||||
)
|
||||
|
||||
if inv_match and live_match:
|
||||
inv_sha = _normalize_sha(inv_match.group(1))
|
||||
live_sha = _normalize_sha(live_match.group(1))
|
||||
if inv_sha and live_sha and inv_sha != live_sha:
|
||||
# Require explicit note that live head was used.
|
||||
if "use live head" not in lower and "live head only" not in lower:
|
||||
reasons.append(
|
||||
"inventory head differs from live head; report must state that "
|
||||
"the live head was used exclusively"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"reasons": reasons,
|
||||
"applicable": True,
|
||||
"inventory_head_sha": _normalize_sha(inv_match.group(1)) if inv_match else None,
|
||||
"live_head_sha": _normalize_sha(live_match.group(1)) if live_match else None,
|
||||
"classification": (
|
||||
class_match.group(1).strip().lower().replace("-", "_").replace(" ", "_")
|
||||
if class_match
|
||||
else None
|
||||
),
|
||||
}
|
||||
+1751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
"""Controller-closure baseline-proof gate (#529, criterion 6).
|
||||
|
||||
A controller (or any session) may close a tracking issue with a closure
|
||||
report that summarizes validation. When that report calls a non-zero
|
||||
test-suite exit an "expected pre-existing failure" (or baseline / known
|
||||
failure) it must carry pre-merge proof; otherwise the closure buries an
|
||||
unproven regression as an accepted baseline and weakens controller
|
||||
confidence in the durable state.
|
||||
|
||||
This module fails closed on exactly that case by reusing the pre-merge
|
||||
baseline proof verifier (#533). A closure report is allowed when it is
|
||||
empty (no validation claim), a clean pass, or a baseline failure proven on
|
||||
the PR pre-merge base commit (or a documented known-failure record that
|
||||
predates the PR).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from premerge_baseline_proof import CLEAN_PASS, assess_premerge_baseline_proof
|
||||
|
||||
|
||||
def assess_controller_closure_baseline_proof(
|
||||
closure_report: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether an issue-closure report may proceed.
|
||||
|
||||
Returns a dict with ``block``, ``proven``, ``label``, ``reasons``,
|
||||
``skipped`` and ``safe_next_action``. Only blocks when the closure
|
||||
report claims a non-zero validation exit is an expected
|
||||
pre-existing/baseline failure without valid pre-merge proof.
|
||||
"""
|
||||
text = (closure_report or "").strip()
|
||||
if not text:
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"label": CLEAN_PASS,
|
||||
"reasons": [],
|
||||
"skipped": True,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
result = assess_premerge_baseline_proof(text)
|
||||
block = bool(result.get("block"))
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"label": result.get("label"),
|
||||
"reasons": list(result.get("reasons") or []),
|
||||
"skipped": bool(result.get("skipped", False)),
|
||||
"safe_next_action": (
|
||||
result.get("safe_next_action")
|
||||
or (
|
||||
"provide pre-merge baseline proof (base commit, tested commit, "
|
||||
"command, exit status, failure signature) before closing on an "
|
||||
"expected pre-existing failure"
|
||||
)
|
||||
)
|
||||
if block
|
||||
else "",
|
||||
}
|
||||
+53
-1
@@ -29,6 +29,7 @@ from gitea_auth import (
|
||||
get_credentials, resolve_remote, add_remote_args,
|
||||
api_request, repo_api_url,
|
||||
)
|
||||
import issue_workflow_labels
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -38,6 +39,16 @@ def main(argv=None):
|
||||
parser.add_argument("--body", default="", help="Issue body text.")
|
||||
parser.add_argument("--body-file",
|
||||
help="Read issue body from this file ('-' for stdin).")
|
||||
parser.add_argument("--label", action="append", default=[],
|
||||
help="Existing label name to apply; may be repeated.")
|
||||
parser.add_argument("--type-label",
|
||||
help="Issue type, e.g. feature or type:feature.")
|
||||
parser.add_argument("--status-label",
|
||||
help="Initial status, e.g. ready or status:ready.")
|
||||
parser.add_argument("--discussion", action="store_true",
|
||||
help="Apply type:discussion.")
|
||||
parser.add_argument("--require-workflow-labels", action="store_true",
|
||||
help="Fail unless one type:* and one status:* label are supplied.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
host, org, repo = resolve_remote(args)
|
||||
@@ -50,6 +61,24 @@ def main(argv=None):
|
||||
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
|
||||
requested_labels = issue_workflow_labels.labels_for_new_issue(
|
||||
issue_type=args.type_label,
|
||||
initial_status=args.status_label,
|
||||
extra_labels=args.label,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
label_assessment = issue_workflow_labels.assess_issue_labels(
|
||||
requested_labels,
|
||||
discussion=args.discussion,
|
||||
)
|
||||
if args.require_workflow_labels and not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label validation failed: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
user, password = get_credentials(host)
|
||||
if not user or not password:
|
||||
print(f"Could not get credentials for {host} "
|
||||
@@ -59,11 +88,34 @@ def main(argv=None):
|
||||
|
||||
import base64
|
||||
auth = f"Basic {base64.b64encode(f'{user}:{password}'.encode()).decode()}"
|
||||
url = f"{repo_api_url(host, org, repo)}/issues"
|
||||
base = repo_api_url(host, org, repo)
|
||||
url = f"{base}/issues"
|
||||
|
||||
try:
|
||||
label_ids = []
|
||||
if requested_labels:
|
||||
existing = api_request("GET", f"{base}/labels", auth)
|
||||
by_name = {lb["name"]: lb["id"] for lb in existing}
|
||||
missing = [name for name in requested_labels if name not in by_name]
|
||||
if missing:
|
||||
print(f"Missing labels: {missing}", file=sys.stderr)
|
||||
return 1
|
||||
label_ids = [by_name[name] for name in requested_labels]
|
||||
data = api_request("POST", url, auth, {"title": args.title, "body": body})
|
||||
if label_ids:
|
||||
api_request(
|
||||
"PUT",
|
||||
f"{base}/issues/{data.get('number')}/labels",
|
||||
auth,
|
||||
{"labels": label_ids},
|
||||
)
|
||||
print(f"Issue #{data.get('number')}: {data.get('html_url')}")
|
||||
if not label_assessment["valid"]:
|
||||
print(
|
||||
"Workflow label recommendation: "
|
||||
+ "; ".join(label_assessment["errors"]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Control-plane DB substrate (#613)
|
||||
|
||||
**Status:** Implemented (SQLite single-writer MVP)
|
||||
|
||||
**ADR:** [`mcp-allocator-control-plane-observability-adr.md`](mcp-allocator-control-plane-observability-adr.md)
|
||||
|
||||
**Module:** `control_plane_db.py`
|
||||
|
||||
## Architecture statement
|
||||
|
||||
> **DB coordinates, Gitea records, Sentry/GlitchTip observe; the bridge is the only path that turns observations into Gitea work.**
|
||||
|
||||
## What this ships
|
||||
|
||||
| Capability | Notes |
|
||||
|------------|--------|
|
||||
| Schema | `sessions`, `work_items`, `leases`, `assignments`, `terminal_locks`, `events`, `incident_links` |
|
||||
| Atomic assign+lease | `ControlPlaneDB.assign_and_lease` — one `BEGIN IMMEDIATE` transaction |
|
||||
| Mutation gate | `require_valid_assignment` — live lease + allowed action + non-terminal work + non-stale head |
|
||||
| Heartbeat / release / expire | Lease lifecycle helpers |
|
||||
| Terminal-lock index | Routing signal for #600 (terminal path first) |
|
||||
| `incident_links` | Provider-neutral link model for #612 — **not** assignable work; scope keys NULL-safe |
|
||||
|
||||
## Hard rules (enforced in code)
|
||||
|
||||
1. Assignable `work_items.kind` ∈ {`issue`, `pr`} only — **never** raw Sentry/GlitchTip incidents.
|
||||
2. Two concurrent sessions cannot both receive an active assignment on the same open work item (second gets `wait`).
|
||||
3. Merged/closed work items return `no_safe_work` at assign time, and `require_valid_assignment` fails closed if the work item becomes terminal later.
|
||||
4. Assignments pin `expected_head_sha`; mutations fail closed if the work item head drifts.
|
||||
5. SQLite path is the **single-writer MVP** (`GITEA_CONTROL_PLANE_DB`, default under `~/.cache/gitea-tools/control-plane/`). Multi-session multi-host production requires **Postgres** or a **single allocator daemon** (ADR §6).
|
||||
6. `incident_links` optional scope fields are stored as empty strings (never NULL) so UNIQUE is canonical across minimal upserts.
|
||||
7. Legacy `incident_links` migration collapses NULL-scope duplicates **only** when Gitea targets **and** all meaningful observation metadata agree (fingerprint, status, event_count, permalink, timestamps, linked PRs, etc.). Conflicting metadata fails closed — no silent discard.
|
||||
|
||||
## Dependency chain
|
||||
|
||||
```text
|
||||
#613 control-plane DB (this) → #600 allocator API → #612 incident bridge
|
||||
```
|
||||
|
||||
- **#600** must call this substrate (not file locks / comment-only leases alone) for completion.
|
||||
- **#612** must write `incident_links` here and create **Gitea issues**; the allocator assigns those issues, not raw incidents.
|
||||
|
||||
## Allocator API (#600)
|
||||
|
||||
Module: `allocator_service.py` · MCP tool: `gitea_allocate_next_work`
|
||||
|
||||
- Workers call `gitea_allocate_next_work(apply=false|true)` instead of self-selecting work.
|
||||
- `apply=false` returns a dry-run selection (`outcome=preview`) with skip reasons.
|
||||
- `apply=true` reserves via `ControlPlaneDB.assign_and_lease` (atomic assignment+lease).
|
||||
- Coordination source is **always** the control-plane DB — not file locks or comment-only leases.
|
||||
- Routing follows ADR §5.3 (REQUEST_CHANGES → author, terminal path first, foreign lease → wait).
|
||||
- Raw monitoring incidents are never candidates.
|
||||
|
||||
|
||||
## Lease lifecycle API (#601)
|
||||
|
||||
Module: `lease_lifecycle.py` · MCP tools: `gitea_*_workflow_lease(s)`
|
||||
|
||||
Active control-plane leases are **first-class workflow state**:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `gitea_list_workflow_leases` | List active (or all) leases for a repo |
|
||||
| `gitea_inspect_workflow_lease` | Freshness + `safe_next_action` for one lease id |
|
||||
| `gitea_adopt_workflow_lease` | Owner-resume or sanctioned reclaim with provenance |
|
||||
| `gitea_release_workflow_lease` | Explicit owner release (audited) |
|
||||
| `gitea_expire_workflow_leases` | Deterministic expire of past-`expires_at` leases |
|
||||
| `gitea_abandon_workflow_lease` | Abandon with required proof |
|
||||
| `gitea_reclaim_expired_workflow_lease` | Expire + re-assign with provenance |
|
||||
|
||||
### Hard rules
|
||||
|
||||
1. Control-plane DB is the coordination authority — file locks / comment-only leases are not authoritative alone.
|
||||
2. Active foreign leases cannot be stolen; inspect returns `wait_foreign_active`.
|
||||
3. Abandon requires `(dead_process OR missing_worktree)` and `no_live_mutation_risk`; foreign abandon also needs `operator_authorized` or full dead+missing+no_open_pr proof.
|
||||
4. Adopt provenance always records `adopted_from_session_id`, `adopted_by_session_id`, work identity, optional head SHA, and worktree path.
|
||||
5. Reviewer→merger comment handoff (`gitea_adopt_merger_pr_lease`) is unchanged and remains the Gitea-thread durability path.
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/test_lease_lifecycle.py tests/test_control_plane_db.py tests/test_allocator_service.py tests/test_merger_lease_adoption.py -q
|
||||
```
|
||||
|
||||
## Incident bridge (#612)
|
||||
|
||||
Module: `incident_bridge.py` · MCP tools: `gitea_observability_*`
|
||||
|
||||
- Bridge converts Sentry/GlitchTip observations → **normal Gitea issues** + `incident_links`.
|
||||
- Phase-1: `gitea_observability_reconcile_incident(apply=false|true)` with JSON observation.
|
||||
- Dry-run performs **no** Gitea mutation and **no** DB write.
|
||||
- Apply reuses existing links or creates one Gitea issue; never invents `work_items.kind=incident`.
|
||||
- Config: `GITEA_OBSERVABILITY_PROJECTS_JSON` or `GITEA_OBSERVABILITY_PROJECTS_FILE`.
|
||||
- Provider tokens never appear in issue bodies, links, or tool results.
|
||||
- Allocator sees bridge work only after a Gitea issue exists.
|
||||
|
||||
## Non-goals (intentionally deferred)
|
||||
|
||||
- Full unsupervised watchdog auto-filing (prefer explicit reconcile first)
|
||||
- Assuming GlitchTip writeback API equals Sentry without verification
|
||||
- Gitea comment/label mirror writers for every assignment (optional later)
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/test_control_plane_db.py -q
|
||||
```
|
||||
@@ -22,13 +22,13 @@ Strictly read-only, per ADR-0001:
|
||||
tools — never one dual-credential server).
|
||||
- **This server never holds Gitea write credentials.**
|
||||
|
||||
## 2. Boundary placement (namespace pending)
|
||||
## 2. Boundary placement
|
||||
|
||||
These tools belong to the GlitchTip observability boundary of the MCP Control
|
||||
Plane — `glitchtip-mcp` (ADR-0001's recommendation), `observability-mcp`, or
|
||||
folded into `ops-mcp`. **ADR-0001 open owner decision #2 picks the name; this
|
||||
design does not assume it.** Tool names below use the `glitchtip_` prefix for
|
||||
readability and rename mechanically with the decision.
|
||||
Plane. The canonical MCP server name is `glitchtip-mcp`; client registration
|
||||
and reload instructions live in
|
||||
[`../mcp-client-registration.md`](../mcp-client-registration.md). Tool names
|
||||
below use the `glitchtip_` prefix.
|
||||
|
||||
Fixed regardless of the name (per `tool-boundaries.md`,
|
||||
`credential-isolation.md`):
|
||||
@@ -160,11 +160,13 @@ Mocked-GlitchTip unit tests only, per `docs/developer-testing-guidelines.md`:
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement once:
|
||||
Ready to operate once:
|
||||
|
||||
1. ADR-0001 owner decision #2 (namespace/placement) is made — mechanical
|
||||
rename of the `glitchtip_` prefix if needed.
|
||||
2. ADR-0001 owner decision #1 (repo home) is made.
|
||||
1. The MCP client registers the external server under the exact `glitchtip-mcp`
|
||||
name and is reconnected/reloaded.
|
||||
2. Tool discoverability proves the expected `glitchtip_*` read tools are
|
||||
visible; if the server is enabled but exposes no usable tools, report
|
||||
`SKIPPED` and stop.
|
||||
3. #76 profile schema exists (or a minimal `glitchtip-readonly` profile is
|
||||
hand-rolled to the same rules).
|
||||
4. A pinned GlitchTip version is chosen for API-subset testing (§3).
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
# GlitchTip-to-Gitea Issue Filing Workflow Design
|
||||
# GlitchTip-to-Gitea Issue Filing Workflow Contract
|
||||
|
||||
- **Status:** Design (no implementation in this repo)
|
||||
- **Issue:** #74 (parent umbrella: #75)
|
||||
- **Status:** Contract for the library-only implementation in
|
||||
`Scaled-Tech-Consulting/mcp-control-plane` (#57)
|
||||
- **Issue:** #153 (supersedes #74/#78 as the Gitea-Tools tracker)
|
||||
- **Related:** #78 (deduplication design, child of #74)
|
||||
- **Date:** 2026-07-02
|
||||
- **Date:** 2026-07-07
|
||||
|
||||
## 1. Boundary and Orchestration
|
||||
|
||||
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
|
||||
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
|
||||
* The filing capability lives in a **library-only orchestrator** in
|
||||
`mcp-control-plane` and is not exposed through `glitchtip-mcp`.
|
||||
* The orchestrator composes the real GlitchTip read path with Gitea
|
||||
issue-write tools. It must not use mocked GlitchTip issue data in production
|
||||
filing paths.
|
||||
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
|
||||
* If a future MCP surface exposes filing, it must be a separate write-boundary
|
||||
server/profile with explicit Gitea issue-write permission and the same audit
|
||||
gates. It must not be added to the read-only `glitchtip-mcp` surface.
|
||||
|
||||
## 2. Invocation and Safety
|
||||
|
||||
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
|
||||
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
|
||||
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
|
||||
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation
|
||||
relies on `gitea-mcp`, and therefore must pass Gitea profile checks and
|
||||
fail-closed mutation audit before create/link/comment actions.
|
||||
* **Deduplication before create:** The orchestrator must run the
|
||||
GlitchTip-to-Gitea dedup/linking logic before any Gitea create action. Create,
|
||||
link, and skip decisions must be represented in tests.
|
||||
|
||||
## 3. Gitea Issue Format
|
||||
|
||||
@@ -51,6 +64,23 @@ To prevent PII or secret leakage into Gitea, the orchestrator and the underlying
|
||||
|
||||
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
|
||||
|
||||
## 5. Deduplication and Linking (Deferred)
|
||||
## 5. Deduplication and Linking
|
||||
|
||||
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
|
||||
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip
|
||||
issue IDs, and race condition handling) is integrated into the library-only
|
||||
filing orchestrator in `mcp-control-plane`. The orchestrator must reuse that
|
||||
dedup/linking path instead of duplicating or bypassing it.
|
||||
|
||||
## 6. Gitea-Tools Acceptance Contract for #153
|
||||
|
||||
Gitea-Tools does not host the filing implementation. This repository owns the
|
||||
operator-facing contract:
|
||||
|
||||
* `glitchtip-mcp` remains read-only and exposes only GlitchTip inspection tools.
|
||||
* Filing is implemented in `mcp-control-plane`, not in this Gitea MCP runtime.
|
||||
* Filing uses the real GlitchTip read path, not mocked issue data.
|
||||
* Dedup runs before any Gitea create action.
|
||||
* Create/link/skip decisions and audit failure are covered by orchestrator tests.
|
||||
* Mutation audit fails closed before any Gitea create, link, or comment mutation.
|
||||
* Any future MCP exposure requires a separate write-boundary server/profile and
|
||||
must not add Gitea write credentials to `glitchtip-mcp`.
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
- **Related:** #77 (repo/branch/PR → job mapping, designed separately)
|
||||
- **Date:** 2026-07-02
|
||||
|
||||
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The server boundary now contains gated trigger (see #56), but read tools remain as designed.
|
||||
Note on naming: This design used historical `jenkins-readonly` skill name in Gitea-Tools. Actual package/server is `jenkins-mcp` (see mcp-control-plane registration in #55). The read server boundary remains read-only; gated triggers live on the separate `jenkins-write-mcp` / `jenkins_mcp.write_server` boundary (see #56 / #152).
|
||||
Client registration and reload instructions live in
|
||||
[`../mcp-client-registration.md`](../mcp-client-registration.md).
|
||||
|
||||
## 1. Purpose and scope
|
||||
|
||||
@@ -16,7 +18,9 @@ detail (build URL, number, timing, result) to report or investigate.
|
||||
Phase 1 is **primarily read-only**, per ADR-0001
|
||||
([`adr-0001-mcp-control-plane-boundaries.md`](adr-0001-mcp-control-plane-boundaries.md)):
|
||||
|
||||
- Build triggers are gated behind dedicated profile + exact confirmation (landed in #4, boundary correction in #56).
|
||||
- Build triggers are outside this read-only surface and require the separate
|
||||
`jenkins-write-mcp` boundary, a dedicated profile, exact confirmation, and
|
||||
fail-closed mutation audit (landed in #4, boundary correction in #56 / #152).
|
||||
- **Excluded: deploy triggers.**
|
||||
- **Excluded: parameterized job launches.**
|
||||
- Excluded: job creation/deletion/config changes, queue manipulation, node
|
||||
@@ -107,7 +111,7 @@ by #76):
|
||||
`forbidden_operations: ["jenkins.build.trigger", "jenkins.deploy", "jenkins.job.configure"]`
|
||||
as belt-and-braces even though no mutating tool exists.
|
||||
- Missing URL/user/token/profile ⇒ **fail closed** with a clear message.
|
||||
- Since every tool is read-only, no confirmation gates are needed — but
|
||||
- Since every tool on `jenkins-mcp` is read-only, no confirmation gates are needed — but
|
||||
identity (`jenkins_whoami`) must still work so workflows can prove which
|
||||
Jenkins account they act as.
|
||||
|
||||
@@ -141,12 +145,16 @@ repo's conventions (`docs/developer-testing-guidelines.md`):
|
||||
|
||||
## 10. Implementation-readiness checklist
|
||||
|
||||
Ready to implement in `jenkins-mcp` once:
|
||||
Ready to operate through `jenkins-mcp` once:
|
||||
|
||||
1. ADR-0001 owner decision #1 (where `jenkins-mcp` lives) is made.
|
||||
2. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
|
||||
1. The MCP client registers the external server under the exact `jenkins-mcp`
|
||||
name and is reconnected/reloaded.
|
||||
2. Tool discoverability proves the expected `jenkins_*` read tools are visible;
|
||||
if the server is enabled but exposes no usable tools, report `SKIPPED` and
|
||||
stop.
|
||||
3. #76 profile schema exists (or a minimal `jenkins-readonly` profile is
|
||||
hand-rolled to the same rules).
|
||||
3. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||
4. #77 mapping design is accepted (or tools ship path-addressed only, mapping
|
||||
deferred).
|
||||
|
||||
Explicitly **not** unlocked by this document: build triggers, deploys,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# ADR: MCP allocator, control-plane DB, and Sentry/GlitchTip incident bridge architecture
|
||||
|
||||
- **Status:** Accepted (implementation pending; blocks code for #600 / #612 / #613)
|
||||
- **Date:** 2026-07-09
|
||||
- **Tracking issues:**
|
||||
- [#613](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/613) — control-plane DB (first)
|
||||
- [#600](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/600) — allocator API (second)
|
||||
- [#612](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/612) — Sentry/GlitchTip incident bridge (third)
|
||||
- **Related:** existing GlitchTip contracts (`glitchtip-to-gitea-workflow-design.md`, `glitchtip-gitea-deduplication-linking-design.md`), trust boundaries (`tool-boundaries.md`, `safety-model.md`), current leases (`pr_work_lease.py`, `issue_lock_store.py`)
|
||||
|
||||
## 1. Context
|
||||
|
||||
Multiple LLM sessions can independently inspect the Gitea queue and start the same issue or PR. Existing coordination (Gitea comment leases, local issue-lock files, labels) often detects collisions **after** work has started. Parallel issues #600, #612, and #613 each describe part of a fix; without one ADR they risk overlapping stores, wrong dependency order, and trust-boundary violations.
|
||||
|
||||
This ADR is the canonical architecture decision **before** implementing those issues.
|
||||
|
||||
## 2. Decision summary (core)
|
||||
|
||||
| Layer | Owns | Must not |
|
||||
|-------|------|----------|
|
||||
| **Control-plane DB** | Sessions, atomic assignment, leases, heartbeats, terminal-lock index, events, incident links | Bypass Gitea workflow gates or replace issue/PR history |
|
||||
| **Gitea** | Durable work record: issues, PRs, comments, labels, reviews, merges | Be the only concurrency lock under multi-session load |
|
||||
| **Sentry / GlitchTip** | Incidents, events, provider UI | Assign work, approve/merge/close, or mutate Gitea outside the bridge |
|
||||
| **Incident bridge** | Provider adapters, reconcile/create Gitea issues, link storage upsert, optional provider writeback | Hand raw provider incidents to the allocator as work items |
|
||||
|
||||
**One-liner:** **DB coordinates. Gitea records. Sentry/GlitchTip observe. The bridge is the only path that turns observations into Gitea work. The allocator assigns only Gitea issues/PRs, never raw monitoring incidents.**
|
||||
|
||||
## 3. Dependency order
|
||||
|
||||
Implementation **must** follow:
|
||||
|
||||
```text
|
||||
#613 control-plane DB → #600 allocator API → #612 incident bridge
|
||||
(atomic substrate) (routing policy) (feeds Gitea work)
|
||||
```
|
||||
|
||||
| Issue | Role | Depends on |
|
||||
|-------|------|------------|
|
||||
| **#613** | Durable coordination DB + lease/assignment transactions | — |
|
||||
| **#600** | `gitea_allocate_next_work` policy and tool surface | **#613** (hard) |
|
||||
| **#612** | Multi-project Sentry/GlitchTip → Gitea bridge | **#613** for `incident_links` index; **#600** before treating bridge-created issues as allocator feed in multi-worker prod |
|
||||
|
||||
**Hard rules:**
|
||||
|
||||
1. Do **not** implement #600 on file locks / comment-only leases and call it done.
|
||||
2. Do **not** ship #612 as a second assignment system for raw incidents.
|
||||
3. Partial previews (read-only list tools, schema stubs) may land earlier if they do not claim “allocator complete” or “bridge complete.”
|
||||
|
||||
## 4. Authority boundaries
|
||||
|
||||
### 4.1 Gitea (durable source of truth for work)
|
||||
|
||||
Gitea remains authoritative for:
|
||||
|
||||
- Issue and PR identity, titles, bodies, state (open/closed/merged)
|
||||
- Comments, reviews, approvals, REQUEST_CHANGES
|
||||
- Labels and workflow status labels (`status:ready`, `status:in-progress`, …)
|
||||
- Merges, closes, branch refs as recorded by Gitea/git hosting
|
||||
|
||||
Operators and auditors read **history** from Gitea.
|
||||
|
||||
### 4.2 Control-plane DB (coordination / index)
|
||||
|
||||
The control-plane DB is authoritative for **live multi-session coordination**:
|
||||
|
||||
- Which session holds which assignment/lease
|
||||
- Heartbeat freshness and expiry
|
||||
- Terminal-lock index for routing
|
||||
- Event log of allocation/lease transitions
|
||||
- `incident_links` index (see §8)
|
||||
|
||||
It is **not** a substitute for Gitea history. When DB and Gitea disagree on durable work state (e.g. PR already merged), **Gitea wins**; the DB is reconciled.
|
||||
|
||||
### 4.3 Sentry / GlitchTip (observe)
|
||||
|
||||
Providers own incident lifecycle and raw event data. They:
|
||||
|
||||
- Must **not** bypass Gitea gates
|
||||
- Must **not** assign LLM work
|
||||
- May receive **writeback** of resolution status only through the bridge, when configured and supported
|
||||
|
||||
### 4.4 Trust boundaries for credentials
|
||||
|
||||
Aligned with `docs/tool-boundaries.md` and `docs/safety-model.md`:
|
||||
|
||||
- **One MCP server process per trust boundary** remains the default.
|
||||
- Monitor API tokens (Sentry/GlitchTip) live in the **bridge/orchestrator boundary** (or a dedicated observability write/read profile), **not** blindly inside every Gitea MCP author/reviewer/merger process.
|
||||
- Gitea tokens stay on Gitea MCP profiles only.
|
||||
- Orchestrators compose services; they must not become a single credential pool that silently mixes Gitea write + monitor tokens into every worker.
|
||||
|
||||
Tool names such as `gitea_observability_*` may exist as a **namespace façade** only if the runtime still enforces separate credential scopes (e.g. bridge process vs pure Gitea mutation process).
|
||||
|
||||
## 5. Allocator rules (#600 on top of #613)
|
||||
|
||||
### 5.1 Worker contract
|
||||
|
||||
- Workers **do not self-select** work under the standard multi-LLM workflow.
|
||||
- Workers call **`gitea_allocate_next_work`** (with `apply=true` only when taking work).
|
||||
- Mutations that claim exclusive work require a **valid assignment and lease** from the control-plane DB.
|
||||
- Return values include at least: role, issue/PR number, expected head SHA (when applicable), lease ID, expiry, allowed actions, forbidden actions — or a non-assignment outcome (`WAIT`, terminal-path block, no safe work, needs controller, etc.).
|
||||
|
||||
### 5.2 Atomic reserve
|
||||
|
||||
- **Assignment creation and lease creation happen in one DB transaction.**
|
||||
- Two concurrent sessions **must not** receive the same issue/PR as assigned work.
|
||||
- On contention: second session gets **WAIT** or **owner-resume**, never a duplicate lease.
|
||||
|
||||
### 5.3 Routing policy
|
||||
|
||||
| Condition | Route |
|
||||
|-----------|--------|
|
||||
| Current-head **REQUEST_CHANGES** | **Author** (not reviewer/merger) |
|
||||
| **Stale** approval (approval not on current head) | **Reviewer** |
|
||||
| **Clean** approval on current head, mergeable | **Merger** |
|
||||
| Contested / contaminated approval | **Diagnosis / reconciler** (controller path) |
|
||||
| Active **foreign** lease | **WAIT** or **owner-resume** |
|
||||
| **Terminal-review** lock present | **Terminal-path resolution first** before downstream review work |
|
||||
| Merged / closed PR | **Never assign** |
|
||||
| Issue locked by sanctioned lease | **Never assign** to another worker unless lease cleanup/adoption is sanctioned |
|
||||
|
||||
### 5.4 Relationship to Gitea mirrors
|
||||
|
||||
After a successful assignment, the allocator (or sanctioned tooling) may update Gitea comments/labels so humans and audits see state. Those mirrors **are not** the sole lock source.
|
||||
|
||||
## 6. Control-plane DB topology (#613)
|
||||
|
||||
### 6.1 Preferred architecture (multi-daemon / Proxmox)
|
||||
|
||||
For true multi-session concurrency (multiple MCP daemons, multiple hosts, or Proxmox VMs):
|
||||
|
||||
**Prefer either:**
|
||||
|
||||
1. **Shared Postgres** used by all Gitea MCP profiles / allocator callers, **or**
|
||||
2. A **single allocator daemon** (sole writer to the coordination store) that all workers call over MCP/RPC.
|
||||
|
||||
Both satisfy: one transactional authority for “who has this work item.”
|
||||
|
||||
### 6.2 SQLite MVP
|
||||
|
||||
SQLite is acceptable **only** for a **single-writer MVP**:
|
||||
|
||||
- One process owns writes (allocator daemon or single co-located MCP server), **or**
|
||||
- Documented single-host single-daemon experiments with file locking and no multi-host claims.
|
||||
|
||||
SQLite is **not** sufficient to declare multi-session production readiness when four independent LLM sessions talk to four MCP processes without a shared writer.
|
||||
|
||||
### 6.3 Suggested entities (normative shape)
|
||||
|
||||
Minimum logical entities (names may vary; semantics must not):
|
||||
|
||||
1. **sessions** — session_id, role/profile, namespace, pid, started_at, last_heartbeat_at, status
|
||||
2. **work_items** — remote/org/repo, kind ∈ {`issue`, `pr`}, number, state, priority, current_head_sha, updated_at
|
||||
3. **leases** — lease_id, work_item_id, session_id, role, phase, expires_at, heartbeat_at, status
|
||||
4. **assignments** — assignment_id, work_item_id, session_id, allowed_action(s), expected_head_sha, status
|
||||
5. **terminal_locks** — repo/org, terminal_pr, review_id, decision, status, cleanup_state
|
||||
6. **events** — event_id, work_item_id, type, message, created_at
|
||||
7. **incident_links** — provider-neutral link model (see §8)
|
||||
|
||||
**Explicit non-kind:** do **not** use `work_items.kind = sentry_incident` (or glitchtip_incident) as an assignable work unit. Incidents become work only after the bridge creates/links a **Gitea issue**.
|
||||
|
||||
## 7. Lease migration (avoid split-brain)
|
||||
|
||||
### 7.1 Target state
|
||||
|
||||
- **Primary** for live coordination: **control-plane DB** leases and assignments.
|
||||
- **Gitea comment leases** (e.g. `<!-- mcp-review-lease:v1 -->`) and **local issue-lock files** become:
|
||||
- **mirrors** of DB state for human visibility / recovery, and/or
|
||||
- written **only** through the allocator (or sanctioned lease tools that update DB + mirror in one workflow).
|
||||
|
||||
### 7.2 Migration rules
|
||||
|
||||
1. New exclusive claims go through DB assignment+lease first.
|
||||
2. Comment lease / file lock writers that skip the DB are **deprecated** once #613+#600 land.
|
||||
3. Reconciler tooling heals: expired DB lease, orphan Gitea comment, orphan local file — fail closed when ambiguous.
|
||||
4. **Split-brain is a defect:** “DB free + Gitea comment leased” or “DB leased + Gitea free” must be detectable and reconcilable; production paths must not rely on two independent writers.
|
||||
|
||||
### 7.3 Heartbeats
|
||||
|
||||
- Heartbeats update the **DB**.
|
||||
- Optional Gitea summaries must avoid comment spam (periodic summary or edit-in-place policy).
|
||||
|
||||
## 8. Observability bridge (#612)
|
||||
|
||||
### 8.1 Role
|
||||
|
||||
The bridge:
|
||||
|
||||
1. Scans configured Sentry and/or GlitchTip projects (multi-project mappings).
|
||||
2. Deduplicates against existing links / Gitea issues.
|
||||
3. Creates or updates **normal Gitea issues** (labels, sanitized body, provider URL).
|
||||
4. Upserts **one** canonical `incident_links` row.
|
||||
5. Optionally writes resolution status back to the provider when supported.
|
||||
6. Never approves, merges, closes, or otherwise bypasses Gitea workflow gates.
|
||||
|
||||
### 8.2 Allocator interaction
|
||||
|
||||
- The allocator sees observability work **only after** a Gitea issue exists (typically `status:ready` + observability labels).
|
||||
- **Do not assign raw Sentry/GlitchTip incidents** as work items.
|
||||
- Bridge AC “allocator can see linked issues” means: **as ordinary Gitea issues**, not a special incident queue.
|
||||
|
||||
### 8.3 Provider adapters and trust
|
||||
|
||||
- Separate **Sentry** and **GlitchTip** adapters; document API differences; do not assume writeback parity.
|
||||
- Self-hosted base URLs supported (e.g. `https://sentry.prgs.cc`).
|
||||
- Tokens from environment / secret store only.
|
||||
- Prefer phase-1 **explicit reconcile / dry-run** before unsupervised watchdog auto-filing, consistent with existing GlitchTip filing safety contracts.
|
||||
|
||||
### 8.4 Home of implementation
|
||||
|
||||
Filing/orchestration may live in:
|
||||
|
||||
- a control-plane / bridge package or profile, and/or
|
||||
- Gitea-Tools as operator-facing tools that **delegate** to that boundary,
|
||||
|
||||
but must not violate §4.4 credential isolation.
|
||||
|
||||
## 9. Incident link storage (single source of truth)
|
||||
|
||||
### 9.1 Canonical model: `incident_links` (provider-neutral)
|
||||
|
||||
Prefer a **provider-neutral** model over Sentry-only `sentry_links`:
|
||||
|
||||
| Field (logical) | Purpose |
|
||||
|-----------------|---------|
|
||||
| provider | `sentry` \| `glitchtip` \| … |
|
||||
| provider_base_url | Self-hosted base |
|
||||
| provider_org / provider_project | Mapping key |
|
||||
| provider_issue_id / provider_short_id | Provider identity |
|
||||
| provider_permalink | Human URL |
|
||||
| fingerprint | Stable dedupe key when available |
|
||||
| gitea_org / gitea_repo / gitea_issue_number | Linked work |
|
||||
| linked_pr_numbers | Optional PR association |
|
||||
| first_seen / last_seen / event_count | Summary metrics (safe) |
|
||||
| status | link lifecycle |
|
||||
| release_resolved_at / last_sync_at | Sync bookkeeping |
|
||||
|
||||
### 9.2 Gitea as human mirror
|
||||
|
||||
- Issue body markers / structured comments (e.g. HTML comment metadata) remain the **human- and audit-visible mirror**.
|
||||
- They must stay consistent with `incident_links` but are **not** a second independent write path for inventing links without the bridge.
|
||||
|
||||
### 9.3 One truth
|
||||
|
||||
- **Do not** maintain two independent systems of record (e.g. full mapping only in #612 storage **and** a separate #613 `sentry_links` with different semantics).
|
||||
- #612 implementation **must** use the same `incident_links` model introduced under #613 (or a single agreed store both reference).
|
||||
|
||||
## 10. Security and redaction
|
||||
|
||||
Mandatory for bridge payloads, Gitea issue bodies/comments, DB-stored event summaries, and logs:
|
||||
|
||||
- No API tokens, DSNs, passwords, cookies, auth headers
|
||||
- No keychain IDs, private config contents, raw session-state files, full prompt bodies
|
||||
- Sanitize stack locals and request data; store only safe tags/context
|
||||
- Logs must never print provider tokens or DSNs
|
||||
- Redaction tests are required for #612
|
||||
|
||||
## 11. Non-goals
|
||||
|
||||
- Replace Gitea as the durable workflow record
|
||||
- Let Sentry/GlitchTip assign work or mutate Gitea workflow state outside sanctioned tools
|
||||
- Let the bridge approve, merge, close, release, or bypass Gitea gates
|
||||
- Implement #600 on file locks / comments alone and call allocator complete
|
||||
- Treat raw monitoring incidents as `work_items` for the allocator
|
||||
- Put monitor tokens into every Gitea MCP worker process by default
|
||||
|
||||
## 12. Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Clear implementation order and ownership
|
||||
- Multi-session safety becomes testable at the DB layer
|
||||
- Observability becomes assignable work without special-casing the allocator
|
||||
- Trust boundaries stay compatible with existing control-plane docs
|
||||
|
||||
### Costs / risks
|
||||
|
||||
- Migration from comment/file leases requires reconciler work
|
||||
- Shared Postgres or single allocator daemon is operational cost
|
||||
- Bridge must be careful not to spam Gitea issues (dedupe, caps, policy modes)
|
||||
|
||||
### Follow-ups (documentation only until implemented)
|
||||
|
||||
1. Update #600, #612, and #613 bodies to **link this ADR** and restate hard dependencies.
|
||||
2. Implement #613 schema + atomic assign/lease.
|
||||
3. Implement #600 against the DB.
|
||||
4. Implement #612 against `incident_links` + Gitea create/update.
|
||||
5. Deprecate dual writers for leases.
|
||||
|
||||
## 13. Acceptance criteria for *this* ADR
|
||||
|
||||
This document is accepted when:
|
||||
|
||||
1. It is merged into `docs/architecture/` on the default branch.
|
||||
2. #600 / #612 / #613 (or their PRs) reference this path as the architecture source of truth.
|
||||
3. No implementation PR for those issues claims completion without conforming to §§2–11.
|
||||
|
||||
## 14. Document history
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-07-09 | Initial ADR: dependency order, authority model, topology, lease migration, bridge, `incident_links`, security, non-goals |
|
||||
@@ -0,0 +1,190 @@
|
||||
# Bootstrap Review Path for Self-Hosted MCP Workflow Fixes (#557)
|
||||
|
||||
## Purpose
|
||||
|
||||
Gitea-Tools MCP workflow fixes can create a **bootstrap deadlock**: the live
|
||||
MCP daemons still run the broken code from `master`, so canonical reviewer /
|
||||
merger tools cannot complete the review that would land the fix.
|
||||
|
||||
This document defines a **narrow, controller-authorized bootstrap path** so
|
||||
such fixes can land without weakening normal review/merge gates.
|
||||
|
||||
It is **not** a general bypass. Normal PRs must still use the full
|
||||
reviewer → merger MCP workflow.
|
||||
|
||||
## When this path applies
|
||||
|
||||
All of the following must be true:
|
||||
|
||||
1. The PR changes **Gitea-Tools MCP workflow / preflight / lease / gate** code
|
||||
that the live daemon must load to review itself (self-hosted control plane).
|
||||
2. Canonical review or merge tools **fail closed** because of that defect
|
||||
(documented tool errors, not “inconvenience”).
|
||||
3. A **controller** records an explicit bootstrap authorization on the PR or
|
||||
linked issue (see below).
|
||||
4. The PR source is clean, testable, and free of unrelated scope.
|
||||
|
||||
Example (historical): PR #553 / Issue #546 (reviewer preflight capability/lease
|
||||
deadlock). Live daemons on unpatched `master` could not complete canonical
|
||||
review of the fix PR.
|
||||
|
||||
## Durable authorization (required)
|
||||
|
||||
**No manual remote merge, force-merge, or API merge of a bootstrap PR is
|
||||
allowed** unless a controller has posted a durable authorization record on
|
||||
Gitea.
|
||||
|
||||
### Authorization record (issue or PR comment)
|
||||
|
||||
The controller comment **must** include a machine-readable marker and the
|
||||
fields below:
|
||||
|
||||
```text
|
||||
## BOOTSTRAP REVIEW AUTHORIZATION (#557)
|
||||
|
||||
Status: APPROVED
|
||||
PR: <number>
|
||||
Issue: <number>
|
||||
Controller: <gitea username>
|
||||
Reason: live MCP runtime cannot canonically review this self-hosted workflow fix
|
||||
Scope: narrow bootstrap only — does not weaken normal PR gates
|
||||
|
||||
Validation evidence:
|
||||
- git diff --check: clean
|
||||
- targeted pytest: <command> → pass
|
||||
- full suite attribution (if non-zero): baseline compared to prgs/master
|
||||
|
||||
Duplicate-PR audit: <none | list and disposition>
|
||||
Mutation ledger audit: <summary or path to proof>
|
||||
Contamination audit: <clean | list contaminated comment/lease IDs and disposition>
|
||||
|
||||
Allowed verification actions used: <list>
|
||||
Forbidden actions NOT used: root checkout edits; raw curl mutation; direct
|
||||
module import of gitea_mcp_server for mutations; in-memory gate restoration
|
||||
|
||||
Post-land plan:
|
||||
- restart MCP daemons for author/reviewer/merger/reconciler profiles
|
||||
- re-verify with canonical tools (see Post-bootstrap steps)
|
||||
```
|
||||
|
||||
Without this record, bootstrap merge is **forbidden**. Agents must stop with
|
||||
`BLOCKED + DIAGNOSE` rather than improvise a merge.
|
||||
|
||||
## Review gates and proof (still required)
|
||||
|
||||
Bootstrap does **not** skip technical review. It only allows verification and
|
||||
landing when the **live MCP mutation path** cannot complete the review.
|
||||
|
||||
Before authorization, the controller (or a delegated read-only verifier in an
|
||||
isolated worktree) must have:
|
||||
|
||||
| Gate | Requirement |
|
||||
|------|-------------|
|
||||
| Diff hygiene | `git diff --check` clean on the PR tip vs `prgs/master` |
|
||||
| Tests | Targeted tests for the fix pass; full suite either green or failures attributed to baseline `prgs/master` |
|
||||
| Duplicate PR | Open-PR inventory shows no competing open PR for the same issue (or supersession is documented) |
|
||||
| Mutation ledger | Any claimed mutations are ledger-consistent; no silent root edits |
|
||||
| Contamination audit | PR/issue comments and leases classified as clean vs workflow-contaminated |
|
||||
| Scope | Diff limited to the bootstrap issue; no drive-by refactors |
|
||||
|
||||
### Contamination audit (comments / leases)
|
||||
|
||||
Comments or leases created via **raw curl**, **direct Python import of MCP
|
||||
modules**, or **token extraction** are **workflow-contaminated**. They must be
|
||||
listed and must **not** be treated as canonical review proof.
|
||||
|
||||
Only comments posted via **sanctioned MCP reviewer tools** (after the fix is
|
||||
landed and daemons restarted, or during a clean path that did not bypass gates)
|
||||
count as canonical review state.
|
||||
|
||||
Historical example on PR #553:
|
||||
|
||||
| Comment | Disposition |
|
||||
|---------|-------------|
|
||||
| #7247 test / raw API | contaminated |
|
||||
| #7251 lease metadata / raw API | contaminated |
|
||||
| #7252 lease metadata / direct import | contaminated |
|
||||
| #7265 lease metadata / MCP reviewer tools | workflow-clean |
|
||||
|
||||
## Allowed verification actions
|
||||
|
||||
These may run **outside** the broken live mutation path to establish facts:
|
||||
|
||||
- Read-only MCP tools (`gitea_view_pr`, `gitea_list_prs`, `gitea_whoami`,
|
||||
`gitea_get_profile`, inventory, eligibility **reads**).
|
||||
- Isolated `branches/` worktree checkout of the PR head (never root).
|
||||
- `git fetch`, `git log`, `git diff`, `git merge-tree` (read-only analysis).
|
||||
- Targeted `pytest` / `py_compile` inside that worktree.
|
||||
- Controller posting of the bootstrap authorization comment via a healthy
|
||||
MCP profile **if available**; if comment mutation is also deadlocked, a
|
||||
**human operator** posts the authorization in the Gitea UI (still durable on
|
||||
the thread).
|
||||
|
||||
## Forbidden actions (always)
|
||||
|
||||
Even under bootstrap:
|
||||
|
||||
- Editing or committing in the **project root / control checkout**.
|
||||
- Treating root as a worktree for implementation or review mutations.
|
||||
- Raw `curl` / REST mutation with extracted tokens as a normal workflow.
|
||||
- `import gitea_mcp_server` (or sibling modules) from a shell to call mutation
|
||||
helpers and bypass preflight.
|
||||
- In-memory restoration of capability / lease / decision state to “finish”
|
||||
a blocked chain.
|
||||
- Force-push, history rewrite, or deleting evidence comments.
|
||||
- Weakening normal gates in code “temporarily” without a tracked issue/PR.
|
||||
- Self-review or self-merge by the PR author identity.
|
||||
|
||||
## Landing procedure (after APPROVED authorization)
|
||||
|
||||
1. Confirm the authorization record is present and complete on the PR or issue.
|
||||
2. Merge **only** with an independent merger identity when possible.
|
||||
- Preferred: restart/replace the MCP merger daemon with a build that includes
|
||||
the fix (or a one-shot process started from the PR worktree binary) so
|
||||
`gitea_merge_pr` can run under normal gates.
|
||||
- If the live merger daemon still cannot load the fix, a **human operator**
|
||||
may merge via the Gitea UI **only** after the authorization record exists.
|
||||
3. Never merge from contaminated proof alone.
|
||||
|
||||
## Post-bootstrap steps
|
||||
|
||||
### 1. Restart MCP daemons
|
||||
|
||||
After the fix lands on `prgs/master`:
|
||||
|
||||
1. Stop author / reviewer / merger / reconciler MCP server processes for this
|
||||
repo (IDE MCP pool + any long-lived terminals).
|
||||
2. Confirm no stale PID still serves the old code.
|
||||
3. Restart each profile so it loads the updated `master` tree (or installed
|
||||
package path).
|
||||
4. Call `gitea_whoami` / `gitea_get_profile` on each namespace and record
|
||||
profile name + identity.
|
||||
|
||||
### 2. Re-verify with canonical tools
|
||||
|
||||
Example pattern after PR #553-class fixes (lease release / #550-style follow-up):
|
||||
|
||||
1. From a **reviewer** MCP session: acquire/release or inspect the relevant
|
||||
lease with canonical tools only.
|
||||
2. Confirm no direct-import or raw-API path is required.
|
||||
3. Post a short controller or reconciler note: bootstrap complete; normal gates
|
||||
restored.
|
||||
|
||||
If verification still requires a bypass, open a new issue — do **not** extend
|
||||
this bootstrap authorization silently.
|
||||
|
||||
## Explicit non-goals
|
||||
|
||||
- This path does **not** authorize skipping tests, duplicate audits, or
|
||||
contamination audits.
|
||||
- This path does **not** authorize root checkout implementation work.
|
||||
- This path does **not** replace BLOCKED + DIAGNOSE when a non-bootstrap
|
||||
failure occurs (#552).
|
||||
- This path does **not** grant permanent “operator exception” culture.
|
||||
|
||||
## Related
|
||||
|
||||
- Root checkout policy: `docs/llm-workflow-runbooks.md` (Global LLM Worktree Rule, #475)
|
||||
- BLOCKED + DIAGNOSE: issue #552 / skill workflows
|
||||
- Reviewer lease / preflight deadlock class: issues #546, #548, #550
|
||||
- Durable session proofs across daemon pools: issue #559
|
||||
@@ -0,0 +1,183 @@
|
||||
# Canonical State Comments
|
||||
|
||||
Gitea is the durable system of record for workflow continuation. When a
|
||||
comment changes issue, PR, or discussion state, it should leave enough
|
||||
information for the next role to continue without private chat history.
|
||||
|
||||
Canonical comments answer:
|
||||
|
||||
- what state the object is in
|
||||
- who acts next
|
||||
- what the next actor should do
|
||||
- the exact prompt the next actor should run
|
||||
- which proof, blocker, or dependency matters
|
||||
|
||||
Non-workflow discussion comments do not need this template.
|
||||
|
||||
## Issue State
|
||||
|
||||
Use this when an issue becomes ready, blocked, in progress, PR-open,
|
||||
superseded, merged, or otherwise changes workflow direction.
|
||||
|
||||
```text
|
||||
## Canonical Issue State
|
||||
|
||||
STATE:
|
||||
<ready-for-author | in-progress | blocked | PR-open | needs-review | ready-to-merge | merged | closed | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
RELATED_DISCUSSION:
|
||||
<link/reference or none>
|
||||
|
||||
RELATED_PRS:
|
||||
- #...
|
||||
|
||||
BRANCH:
|
||||
<branch or none>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA or none>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs or none>
|
||||
|
||||
BLOCKERS:
|
||||
<blocker and unblock condition, or none>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## PR State
|
||||
|
||||
Use this when a PR needs review, receives changes requested, is approved,
|
||||
is stale, is superseded, or becomes ready for merge.
|
||||
|
||||
```text
|
||||
## Canonical PR State
|
||||
|
||||
STATE:
|
||||
<needs-review | changes-requested | approved | stale-approval | ready-to-merge | merged | blocked | superseded>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | merger | reconciler | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
WHAT_HAPPENED:
|
||||
<latest meaningful event>
|
||||
|
||||
WHY:
|
||||
<decision rationale>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
BASE:
|
||||
<branch>
|
||||
|
||||
HEAD:
|
||||
<branch>
|
||||
|
||||
HEAD_SHA:
|
||||
<40-character SHA>
|
||||
|
||||
REVIEW_STATUS:
|
||||
<none | approved | changes-requested | stale | contaminated>
|
||||
|
||||
VALIDATION:
|
||||
<tests/proofs>
|
||||
|
||||
BLOCKERS:
|
||||
<blockers or none>
|
||||
|
||||
SUPERSEDES:
|
||||
<PRs or none>
|
||||
|
||||
SUPERSEDED_BY:
|
||||
<PR or none>
|
||||
|
||||
MERGE_READY:
|
||||
<yes/no and why>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Discussion Summary
|
||||
|
||||
Discussions should normally have at least five substantive comments before
|
||||
conversion into issues. A controller may waive that only for tiny mechanical,
|
||||
urgent, or explicitly trivial work.
|
||||
|
||||
```text
|
||||
## Canonical Discussion Summary
|
||||
|
||||
STATE:
|
||||
<needs-more-discussion | ready-for-issues | issues-created | closed>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<controller | author | reviewer | user>
|
||||
|
||||
DECISION:
|
||||
<what was decided>
|
||||
|
||||
WHY:
|
||||
<reasoning and tradeoffs>
|
||||
|
||||
SUBSTANTIVE_COMMENTS:
|
||||
<count and summary>
|
||||
|
||||
ISSUES_TO_CREATE_OR_CREATED:
|
||||
- #...
|
||||
|
||||
DEPENDENCY_ORDER:
|
||||
<order or none>
|
||||
|
||||
NON_GOALS:
|
||||
<non-goals>
|
||||
|
||||
OPEN_QUESTIONS:
|
||||
<questions or none>
|
||||
|
||||
NEXT_ACTION:
|
||||
<specific one-sentence action>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next role>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The final-report validator rejects canonical state update claims when the
|
||||
report omits the canonical block or when the block lacks:
|
||||
|
||||
- `STATE`
|
||||
- `WHO_IS_NEXT`
|
||||
- `NEXT_ACTION`
|
||||
- `NEXT_PROMPT`
|
||||
|
||||
It also rejects vague next actions such as `continue`, ready-to-merge states
|
||||
without approval/head-SHA proof, superseded states without canonical item
|
||||
proof, and blocked states without an unblock condition.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Canonical Thread Handoff (CTH)
|
||||
|
||||
**CTH** = **Canonical Thread Handoff**
|
||||
|
||||
A CTH comment is the authoritative workflow handoff in a Gitea issue or PR
|
||||
thread. It records current state, decisions, blockers, proof, and the exact
|
||||
next prompt/action for the next LLM or person.
|
||||
|
||||
CTH comments complement — but do not replace — formal Gitea review state.
|
||||
A CTH may summarize an APPROVE or REQUEST_CHANGES decision, yet merge gates
|
||||
still require the live Gitea review verdict.
|
||||
|
||||
## CTH comment types
|
||||
|
||||
- `CTH: State Handoff`
|
||||
- `CTH: Controller Decision`
|
||||
- `CTH: Author Handoff`
|
||||
- `CTH: Reviewer Handoff`
|
||||
- `CTH: Merger Handoff`
|
||||
- `CTH: Supersession Notice`
|
||||
- `CTH: Blocker`
|
||||
|
||||
## Required base template
|
||||
|
||||
```md
|
||||
## CTH: <Type>
|
||||
|
||||
Status:
|
||||
Next owner:
|
||||
Current blocker:
|
||||
Decision:
|
||||
Proof:
|
||||
Next action:
|
||||
Ready-to-paste prompt:
|
||||
```
|
||||
|
||||
Extended fields may map to canonical issue/PR state templates from #495 when
|
||||
that layer is available. Until then, keep the base fields complete.
|
||||
|
||||
## Discovery and posting rules
|
||||
|
||||
Before acting in an issue or PR thread:
|
||||
|
||||
1. **Find the latest CTH comment** before doing work.
|
||||
2. Treat the **latest valid CTH** as the current handoff state.
|
||||
3. **Post a new CTH** when you finish, block, skip, supersede, request
|
||||
changes, approve, or hand off.
|
||||
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
|
||||
5. Casual discussion comments do not need to be CTH comments.
|
||||
|
||||
## Examples
|
||||
|
||||
### PR approved and ready for merger
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: approved_at_current_head
|
||||
Next owner: merger
|
||||
Current blocker: none
|
||||
Decision: APPROVE recorded at head abc123...
|
||||
Proof: gitea_submit_pr_review performed; visible verdict APPROVE
|
||||
Next action: eligible merger merges PR #N with pinned expected_head_sha
|
||||
Ready-to-paste prompt: Merge PR #N for issue #M if live head still abc123... and merge gates pass.
|
||||
```
|
||||
|
||||
### PR request-changes back to author
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: request_changes_at_current_head
|
||||
Next owner: author
|
||||
Current blocker: unresolved findings in validation report
|
||||
Decision: REQUEST_CHANGES at head def456...
|
||||
Proof: gitea_submit_pr_review performed; blocking review visible
|
||||
Next action: author fixes findings and pushes; reviewer re-validates fresh head
|
||||
Ready-to-paste prompt: Fix PR #N review findings, push to feat/issue-M-..., post Author Handoff CTH.
|
||||
```
|
||||
|
||||
### Duplicate / superseded PR closure
|
||||
|
||||
```md
|
||||
## CTH: Supersession Notice
|
||||
|
||||
Status: superseded
|
||||
Next owner: controller
|
||||
Current blocker: duplicate branch/PR work
|
||||
Decision: close PR #N; continue on PR #M
|
||||
Proof: duplicate gate linked open PR #M for issue #K
|
||||
Next action: controller closes superseded PR and records canonical state
|
||||
Ready-to-paste prompt: Close superseded PR #N; confirm PR #M remains canonical for issue #K.
|
||||
```
|
||||
|
||||
### Blocked workflow due to dirty root/worktree
|
||||
|
||||
```md
|
||||
## CTH: Blocker
|
||||
|
||||
Status: blocked_preflight
|
||||
Next owner: operator
|
||||
Current blocker: dirty control checkout / branches worktree mismatch
|
||||
Decision: stop before mutation
|
||||
Proof: verify_preflight_purity failed; workspace diagnostics attached
|
||||
Next action: repair worktree or relaunch MCP from branches/ worktree
|
||||
Ready-to-paste prompt: Repair dirty workspace, relaunch MCP from branches/review-prN, retry review.
|
||||
```
|
||||
|
||||
### Stale head requiring fresh review
|
||||
|
||||
```md
|
||||
## CTH: Reviewer Handoff
|
||||
|
||||
Status: stale_head
|
||||
Next owner: reviewer
|
||||
Current blocker: live head differs from pinned review head
|
||||
Decision: prior approval not valid for current head
|
||||
Proof: expected_head_sha mismatch at merge gate
|
||||
Next action: re-run validation and post fresh review decision
|
||||
Ready-to-paste prompt: Re-validate PR #N at live head, dry-run review gates, submit fresh verdict.
|
||||
```
|
||||
|
||||
### Issue implementation handoff
|
||||
|
||||
```md
|
||||
## CTH: Author Handoff
|
||||
|
||||
Status: implementation_complete_pending_review
|
||||
Next owner: reviewer
|
||||
Current blocker: none
|
||||
Decision: PR #N ready for review at head fedcba...
|
||||
Proof: tests passed in branches/issue-M-...; PR opened with Closes #M
|
||||
Next action: reviewer acquires lease and validates PR #N
|
||||
Ready-to-paste prompt: Review PR #N for issue #M; pin head fedcba... before mutations.
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [`llm-workflow-runbooks.md`](llm-workflow-runbooks.md)
|
||||
- [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
|
||||
- #495 — canonical next-action comment fields
|
||||
- #496 — fail-closed validation for workflow-changing comments
|
||||
@@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup.
|
||||
|
||||
## 1. Standard test commands
|
||||
|
||||
### Canonical runner: `./run-tests.sh`
|
||||
|
||||
The canonical full-validation command is the root-level runner. It invokes the
|
||||
project virtualenv interpreter and passes any extra arguments straight through
|
||||
to `pytest`:
|
||||
|
||||
```bash
|
||||
# Full validation
|
||||
./run-tests.sh
|
||||
|
||||
# Focused validation (extra args forward to pytest)
|
||||
./run-tests.sh tests/test_mcp_server.py -q
|
||||
```
|
||||
|
||||
`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear
|
||||
setup message if the virtualenv Python is missing (so a session never silently
|
||||
falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest`
|
||||
forms below remain valid and equivalent.
|
||||
|
||||
The test suite needs the project virtualenv (it provides the MCP SDK):
|
||||
|
||||
```bash
|
||||
@@ -35,6 +54,25 @@ Use `-q` for a compact summary and `-v` to see individual test names.
|
||||
./venv/bin/python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
### Web UI suite (#436)
|
||||
|
||||
Hermetic unittest modules matching `test_webui_*.py` cover route rendering,
|
||||
read-only guards, registry/prompt/queue loaders, and optional child-issue
|
||||
modules when present on the branch.
|
||||
|
||||
```bash
|
||||
./scripts/test-webui
|
||||
./scripts/ci-webui-check # skip unless the diff touches web UI paths
|
||||
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check
|
||||
```
|
||||
|
||||
`scripts/test-webui` defaults `WEBUI_TEST_OFFLINE=1` so route coverage never
|
||||
needs Gitea credentials or MCP daemon credential access. Set
|
||||
`WEBUI_TEST_OFFLINE=0` only for explicit operator live-fetch checks.
|
||||
|
||||
Wire `scripts/ci-webui-check` into Jenkins (or equivalent) for PRs that touch
|
||||
`webui/`, `tests/test_webui_*`, or `docs/webui*`.
|
||||
|
||||
### Run targeted tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Two-comment workflow examples (#507)
|
||||
|
||||
Paired `[CONTROLLER HANDOFF]` + `[THREAD STATE LEDGER]` comments for Gitea threads.
|
||||
See `thread_state_ledger_examples.py` for machine-checked fixtures.
|
||||
|
||||
## Approved review posted
|
||||
|
||||
**Handoff** (detailed): identity, worktree, validation commands, mutation ledger with
|
||||
`gitea_submit_pr_review → APPROVED review posted to Gitea`.
|
||||
|
||||
**Ledger** (concise):
|
||||
|
||||
```markdown
|
||||
[THREAD STATE LEDGER] PR #487 — APPROVED review posted to Gitea
|
||||
|
||||
What is true now:
|
||||
- PR state: open
|
||||
- Server-side decision state: APPROVED review posted to Gitea
|
||||
- Local verdict/state: APPROVE verdict prepared locally
|
||||
|
||||
What is blocked:
|
||||
- Blocker classification: no blocker
|
||||
|
||||
Who/what acts next:
|
||||
- Next actor: merger
|
||||
- Required action: merge on explicit operator command
|
||||
- Do not do: re-post APPROVE
|
||||
```
|
||||
|
||||
## Approve validated locally but blocked before posting
|
||||
|
||||
Ledger must show `no server-side state changed` under server-side decision state and
|
||||
`APPROVE verdict prepared locally` under local verdict/state.
|
||||
|
||||
## Environment / tooling blocker
|
||||
|
||||
Ledger blocker classification: `environment/tooling blocker`. Mutation ledger:
|
||||
`none — no server-side state changed`.
|
||||
|
||||
## Stale head blocker
|
||||
|
||||
Ledger: `approval_at_current_head is false`; classification `stale head`.
|
||||
Do not do: merge with stale approval.
|
||||
@@ -22,7 +22,11 @@ launched with exactly one static execution profile:
|
||||
| Namespace (MCP server name) | Profile (role) | Typical use |
|
||||
|-----------------------------|----------------|-------------|
|
||||
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
|
||||
| `gitea-reviewer` | a reviewer profile | review, approve/request changes |
|
||||
| `gitea-merger` | a merger profile | merge PRs after approval and verification |
|
||||
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
Properties:
|
||||
|
||||
@@ -105,6 +109,14 @@ syntax to the client):
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<reviewer-profile-name>"
|
||||
}
|
||||
},
|
||||
"gitea-merger": {
|
||||
"command": "<path-to>/venv/bin/python3",
|
||||
"args": ["<path-to>/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "<path-to-profiles.json>",
|
||||
"GITEA_MCP_PROFILE": "<merger-profile-name>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,31 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever
|
||||
resolves as unknown, agents must fail closed rather than fall back to the
|
||||
edit path.
|
||||
|
||||
## Reconciler profile for already-landed open PRs (#304 / #310)
|
||||
|
||||
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
|
||||
authority. Already-landed open PRs (head SHA is an ancestor of the target
|
||||
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
|
||||
narrow operation set:
|
||||
|
||||
- `gitea.read`
|
||||
- `gitea.pr.comment`
|
||||
- `gitea.issue.comment`
|
||||
- `gitea.issue.close`
|
||||
- `gitea.pr.close`
|
||||
|
||||
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
|
||||
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
|
||||
`gitea.repo.commit`.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
|
||||
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
|
||||
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
|
||||
heads are not already landed cannot be closed through this path.
|
||||
|
||||
## Identity and fail-closed rules
|
||||
|
||||
Before **any** mutating action, a workflow must know both:
|
||||
@@ -286,7 +311,8 @@ To make Gitea MCP profile activation and runtime identity state explicit, the fo
|
||||
### 2. Dual MCP Namespaces Recommendation
|
||||
For security-sensitive or high-risk tasks, the preferred safety model uses separate, isolated MCP server instances (namespaces/sessions) launched with static profiles:
|
||||
- `gitea-author`: Exposes tools configured with author permissions; cannot perform approvals or merges.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews and merges.
|
||||
- `gitea-reviewer`: Exposes tools configured with reviewer permissions; used for PR reviews. Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- `gitea-merger`: Exposes tools configured with merger permissions; used for PR merges.
|
||||
This layout maintains physical separation of credentials and prevents privilege escalation within a single session.
|
||||
This is the model accepted in #139; deployment details, rationale, and client
|
||||
setup live in
|
||||
@@ -298,6 +324,17 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_
|
||||
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
|
||||
This guarantees the active profile operations align with the actual Gitea authenticated user credential.
|
||||
|
||||
## Gitea MCP Runtime Isolation and Worktree Safety
|
||||
|
||||
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
|
||||
|
||||
- **Startup Conflict Check:** The MCP server (`mcp_server.py`) acts as a conflict-free loader. On startup, it scans all Python files in the directory for unresolved git merge conflicts (`<<<<<<<`, `=======`, `>>>>>>>`). If any are found, it prints an `infra_stop` message and exits immediately.
|
||||
- **Workflow Guard:** Before starting reviewer work, task routing checks if the MCP runtime source is mid-merge (by checking for `.git/MERGE_HEAD` or conflict markers). If dirty, it returns `infra_stop` (never `wrong_role_stop` or empty queue) to prevent unsafe mutations.
|
||||
- **Recovery Instructions:** To recover from an `infra_stop` state:
|
||||
1. Resolve all merge conflicts in the local repository or abort the merge (`git merge --abort` / `git rebase --abort`).
|
||||
2. Restart the Gitea MCP server process.
|
||||
3. Retry the task.
|
||||
|
||||
## Relationship to roadmap issues
|
||||
|
||||
This document defines the **model only**. Related work is tracked separately
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Controller Issue-Acceptance Gate
|
||||
|
||||
A merged PR does not automatically prove an issue is fully satisfied. After
|
||||
merge, a controller must audit the linked issue against its acceptance criteria
|
||||
and post a durable handoff before the issue is treated as complete.
|
||||
|
||||
## Workflow position
|
||||
|
||||
1. Author implements the issue and opens a PR.
|
||||
2. Reviewer reviews the PR.
|
||||
3. Merger merges the approved PR.
|
||||
4. **Controller performs issue-acceptance audit.**
|
||||
5. Controller posts a `## Controller Issue Acceptance` comment with either:
|
||||
- `STATE: accepted` and checked criteria, or
|
||||
- a rejection path (`more-work-required`, `needs-tests`, `needs-docs`, etc.)
|
||||
with `MISSING_WORK` and a paste-ready `NEXT_PROMPT`.
|
||||
|
||||
Gitea may auto-close an issue via `Closes #N` in the PR body. That closure is
|
||||
merge mechanics only. Controller acceptance is still required before any final
|
||||
report or queue controller treats the issue as complete.
|
||||
|
||||
## Template
|
||||
|
||||
Use `issue_acceptance_gate.render_controller_acceptance_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/controller-issue-acceptance.md`](../skills/llm-project-workflow/templates/controller-issue-acceptance.md).
|
||||
|
||||
## Final-report rules
|
||||
|
||||
Final reports must not claim `issue complete` solely because a PR merged.
|
||||
Either:
|
||||
|
||||
- include a valid `## Controller Issue Acceptance` block with
|
||||
`STATE: accepted`, or
|
||||
- explicitly state `controller acceptance pending` and identify the controller
|
||||
as the next actor.
|
||||
|
||||
`final_report_validator` enforces this through
|
||||
`issue_acceptance_gate.validate_final_report_issue_acceptance()`.
|
||||
|
||||
## Role boundaries
|
||||
|
||||
- Authors must not mark their own issues accepted.
|
||||
- Reviewers must not mark issue acceptance unless acting under controller
|
||||
capability.
|
||||
- Mergers merge PRs; they do not substitute for controller acceptance.
|
||||
|
||||
## Related
|
||||
|
||||
- #495 — canonical next-action comment templates
|
||||
- #496 — fail-closed canonical comment validation before posting
|
||||
- #303 — controller handoff schema for reconciliation workflows
|
||||
+155
-24
@@ -1,34 +1,165 @@
|
||||
# Label Taxonomy
|
||||
|
||||
This document catalogs the issue labels used for MCP workflows, including Jenkins and GlitchTip (observability).
|
||||
This document defines the canonical issue labels used by MCP workflows.
|
||||
|
||||
> **Approval Required:** Do not create or apply new labels in `manage_labels.py` without explicit owner approval of this document.
|
||||
Every issue should carry:
|
||||
|
||||
## Existing Labels
|
||||
- one `type:*` label
|
||||
- one `status:*` label
|
||||
|
||||
* **`jenkins`**
|
||||
* Description: Jenkins integration
|
||||
* Color: `d93f0b`
|
||||
* Use: Used to mark issues, PRs, or tasks that involve the `jenkins-mcp` boundaries, CI/CD designs, or build failures.
|
||||
Discussion-only issues must carry `type:discussion`.
|
||||
|
||||
* **`glitchtip`**
|
||||
* Description: GlitchTip integration
|
||||
* Color: `b60205`
|
||||
* Use: Used to mark issues related to the `glitchtip-mcp` boundary and observability integration.
|
||||
## Issue Type Labels
|
||||
|
||||
## Proposed / Missing Labels
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `type:bug` | Bug or defect |
|
||||
| `type:feature` | Feature or enhancement |
|
||||
| `type:process` | Process or policy work |
|
||||
| `type:workflow` | Workflow automation or guidance |
|
||||
| `type:guardrail` | Safety gate or guardrail |
|
||||
| `type:docs` | Documentation work |
|
||||
| `type:test` | Tests or test infrastructure |
|
||||
| `type:discussion` | Discussion-only issue |
|
||||
| `type:umbrella` | Umbrella or tracker issue |
|
||||
| `type:cleanup` | Cleanup or hygiene work |
|
||||
|
||||
* **`observability`**
|
||||
* Proposed Description: Observability, metrics, and monitoring tasks
|
||||
* Proposed Color: `5319e7`
|
||||
* Use: Broader than GlitchTip alone; covers logging, metrics, traces, and general observability pipeline improvements.
|
||||
## Workflow Status Labels
|
||||
|
||||
* **`source:glitchtip`**
|
||||
* Proposed Description: Issue filed automatically by GlitchTip orchestration
|
||||
* Proposed Color: `b60205`
|
||||
* Use: Applied automatically by the orchestrator when a GlitchTip error event is converted into a Gitea issue.
|
||||
Only one `status:*` label should be active on an issue at a time. When an issue
|
||||
moves forward, tooling must remove the old `status:*` label and apply the new
|
||||
one.
|
||||
|
||||
* **`status:triage`**
|
||||
* Proposed Description: Issue needs human or orchestrator triage
|
||||
* Proposed Color: `fbca04`
|
||||
* Use: Used for incoming issues (especially automated ones like `source:glitchtip`) that have not yet been evaluated for priority or resolution.
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `status:triage` | Issue needs triage |
|
||||
| `status:ready` | Issue is ready for work |
|
||||
| `status:claimed` | Issue is claimed |
|
||||
| `status:in-progress` | Issue is being worked on |
|
||||
| `status:blocked` | Issue is blocked |
|
||||
| `status:needs-review` | Issue work needs review |
|
||||
| `status:pr-open` | A linked PR is open |
|
||||
| `status:changes-requested` | Reviewer requested changes on the linked PR |
|
||||
| `status:approved` | Linked PR is approved |
|
||||
| `status:merged` | Linked PR is merged |
|
||||
| `status:reconcile` | Issue needs reconciliation |
|
||||
| `status:done` | Issue workflow is complete |
|
||||
| `status:duplicate` | Issue is a duplicate |
|
||||
| `status:wontfix` | Issue will not be fixed |
|
||||
|
||||
## Role Ownership Labels (#603)
|
||||
|
||||
A single `role:*` label shows which workflow role currently owns the item. It is
|
||||
advisory visibility only — the control-plane lease (#601) is the source of truth
|
||||
for mutation authority. Only one `role:*` label is active at a time; tooling
|
||||
replaces it on handoff via `transition_role_labels`.
|
||||
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `role:author` | Author currently owns the item |
|
||||
| `role:reviewer` | Reviewer currently owns the item |
|
||||
| `role:merger` | Merger currently owns the item |
|
||||
|
||||
## Hazard Labels (#603)
|
||||
|
||||
Hazard labels are orthogonal warning flags. Unlike `status:*` and `role:*`,
|
||||
**more than one hazard may be active at once**, and a hazard never substitutes
|
||||
for a live lease / PR-state check. Add/remove with `add_hazard_label` /
|
||||
`clear_hazard_label`.
|
||||
|
||||
| Label | Use |
|
||||
| --- | --- |
|
||||
| `hazard:stale-lease` | A stale or expired lease references this item |
|
||||
| `hazard:workflow-contaminated` | Session/workflow state is contaminated; do not mutate |
|
||||
| `hazard:conflicted` | Linked PR has merge conflicts |
|
||||
| `hazard:root-mutation` | Work was mutated in the project root checkout |
|
||||
| `hazard:manual-state` | Session or lease state was edited manually |
|
||||
| `hazard:terminal-blocker` | A terminal review/merge lock blocks progress (#332/#602) |
|
||||
|
||||
Any item that carries `status:blocked` or any `hazard:*` flag must also have a
|
||||
blocking-reason / next-action comment (`requires_blocking_reason`).
|
||||
|
||||
## `state:*` → canonical mapping (#603 migration)
|
||||
|
||||
Issue #603 proposed a parallel `state:*` vocabulary. To avoid a conflicting
|
||||
second lifecycle prefix, those requested states are folded into the existing
|
||||
canonical labels rather than introduced as `state:*`. `state:*` is **not** a
|
||||
supported prefix; use the canonical label on the right.
|
||||
|
||||
| Requested `state:*` | Canonical label |
|
||||
| --- | --- |
|
||||
| `state:needs-triage` | `status:triage` |
|
||||
| `state:claimed` | `status:claimed` |
|
||||
| `state:authoring` | `status:in-progress` |
|
||||
| `state:needs-review` | `status:needs-review` |
|
||||
| `state:reviewing` | `status:needs-review` |
|
||||
| `state:changes-requested` | `status:changes-requested` |
|
||||
| `state:approved` | `status:approved` |
|
||||
| `state:merge-ready` | `status:approved` |
|
||||
| `state:merged` | `status:merged` |
|
||||
| `state:blocked` | `status:blocked` |
|
||||
| `state:terminal-blocker` | `hazard:terminal-blocker` |
|
||||
| `state:abandoned` | `status:wontfix` |
|
||||
|
||||
The transition helpers accept these names as synonyms (e.g.
|
||||
`canonical_status_label("authoring")` → `status:in-progress`), so callers may use
|
||||
the #603 wording while a single canonical status stays active.
|
||||
|
||||
## Allocator Cross-Check (#603)
|
||||
|
||||
Labels are advisory queue hints. The work allocator (#600/#613) uses labels as
|
||||
one signal but **cross-checks live leases and PR state** and never trusts labels
|
||||
alone. Discussion issues (`type:discussion`) are excluded from implementation
|
||||
queues (`is_implementation_candidate`) unless a controller explicitly selects
|
||||
them.
|
||||
|
||||
## Transition Rules
|
||||
|
||||
Suggested lifecycle:
|
||||
|
||||
1. New issue created: `status:triage` or `status:ready`
|
||||
2. Issue selected by an author: `status:claimed`
|
||||
3. Author starts work: `status:in-progress`
|
||||
4. Work is blocked: `status:blocked`
|
||||
5. PR opened: `status:pr-open`
|
||||
6. PR approved: `status:approved`
|
||||
7. PR merged but issue still needs closure/reconciliation: `status:reconcile`
|
||||
8. Issue fully complete: `status:done`
|
||||
9. Duplicate issue: `status:duplicate`
|
||||
10. Won't-fix issue: `status:wontfix`
|
||||
|
||||
The helper module `issue_workflow_labels.py` is the source of truth for the
|
||||
canonical label specs and status transition replacement behavior.
|
||||
|
||||
## Discussion Issues
|
||||
|
||||
Discussion issues must be labeled `type:discussion`.
|
||||
|
||||
A discussion issue should not be treated as implementation-ready unless it also
|
||||
has a clear implementation status and next action.
|
||||
|
||||
If a discussion produces implementation work, either:
|
||||
|
||||
1. convert the discussion issue into an implementation issue by changing labels
|
||||
and adding acceptance criteria, or
|
||||
2. create child implementation issues and leave the discussion issue as
|
||||
`type:discussion`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `manage_labels.py --create-labels` creates the canonical `type:*` and
|
||||
`status:*` labels.
|
||||
- `gitea_create_issue` recommends `type:*` and `status:*` labels when missing
|
||||
and can apply supplied label names.
|
||||
- `gitea_mark_issue(..., action="start")` replaces old `status:*` labels with
|
||||
`status:in-progress`.
|
||||
- `gitea_create_pr` fails closed before PR creation if `status:pr-open` cannot
|
||||
be applied to the locked issue, then applies it after the PR is created.
|
||||
- `gitea_set_issue_labels` accepts an explicit `worktree_path` so author
|
||||
sessions can satisfy the branches-only mutation guard while changing labels.
|
||||
|
||||
## Existing Non-Workflow Labels
|
||||
|
||||
Existing non-workflow labels such as `mcp`, `workflow`, `labels`, `tracker`,
|
||||
`jenkins`, `glitchtip`, `documentation`, and `testing` remain valid topical
|
||||
labels. They do not replace the required `type:*` and `status:*` labels.
|
||||
|
||||
+707
-10
@@ -7,6 +7,11 @@ package of the MCP Control Plane: creating issues, implementing them, opening
|
||||
and reviewing pull requests, merging, and closing out — safely and
|
||||
reproducibly.
|
||||
|
||||
Canonical state comments for durable issue/PR/discussion continuation are
|
||||
documented in [`canonical-state-comments.md`](canonical-state-comments.md).
|
||||
Use them when a workflow-changing comment needs to leave the next actor, next
|
||||
action, and paste-ready prompt in Gitea.
|
||||
|
||||
> For the **project-agnostic** version of these operating rules (issue-first,
|
||||
> isolated worktrees, no self-review/merge, profile safety, cleanup, fail-closed)
|
||||
> that can be copied into any repository, see the reusable skill
|
||||
@@ -28,8 +33,15 @@ audit logging). See [Related documents](#related-documents).
|
||||
> to discover the available project workflows and `mcp_get_skill_guide(<name>)`
|
||||
> for step-by-step instructions. This replaces long pasted operator prompts for
|
||||
> the standard rules; operator prompts still control task-specific scope.
|
||||
>
|
||||
> **BLOCKED + DIAGNOSE (default for any missing required step):** If a required workflow skill, guide, tool, capability, preflight, terminal, worktree binding, profile, or instruction is unavailable or fails, STOP. State BLOCKED. Use the canonical blocker report template (see skills/llm-project-workflow/templates/blocked-diagnose-report.md and the llm-project-workflow/SKILL.md universal rules). Only non-mutating recovery. Report fully. No unsafe fallbacks (temp scripts, direct API, MCP internals, direct imports, in-memory restoration, manual bypasses) unless controller authorizes in the handoff for this case. Missing required steps must fail closed *before* any git or Gitea mutation. Controller prompts and all workflows must reinforce: BLOCKED + DIAGNOSE, then stop.
|
||||
> See issue #129 for the skill registry design.
|
||||
|
||||
Jenkins and GlitchTip workflows use separate MCP servers, not this Gitea MCP
|
||||
runtime. Register them as `jenkins-mcp` and `glitchtip-mcp`, reconnect or
|
||||
reload the client, and verify visible tools before claiming either integration
|
||||
is usable. See [`mcp-client-registration.md`](mcp-client-registration.md).
|
||||
|
||||
For cross-project use, copy the portable workflow skill at
|
||||
[`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
It extracts the issue-first, isolated-worktree, no-self-review, profile-safety,
|
||||
@@ -164,6 +176,25 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
|
||||
* `mcp__gitea-reviewer__*` (for reviewing PRs, approving, requesting changes, merging)
|
||||
* **Trust Model:** Separate tokens remain separate in the keychain/environment. Each instance operates under its own `GITEA_MCP_PROFILE` and enforces its own `allowed_operations`. A runtime `whoami` identity check is still performed independently, and self-review/self-merge checks remain strictly mandatory. The dual-server pattern is a operational convenience and never a security bypass.
|
||||
* **Reviewer-Identity PR Creation Deadlock:** Reviewer/merge identities must not create PRs or push branches. Doing so makes the reviewer identity the PR author in Gitea, blocking subsequent independent review and causing a review deadlock. Normally, PRs must be created by the author/work identity (`gitea-author`), leaving the reviewer identity (`gitea-reviewer`) clean and available for independent review and merge.
|
||||
* **Reconciler namespace (#310):** Register a third static instance for
|
||||
already-landed PR cleanup when review queues block on open PRs whose heads
|
||||
already landed on `master`:
|
||||
|
||||
```json
|
||||
"gitea-reconciler": {
|
||||
"command": "/path/to/Gitea-Tools/venv/bin/python3",
|
||||
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
|
||||
"env": {
|
||||
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
|
||||
"GITEA_MCP_PROFILE": "prgs-reconciler"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The reconciler profile grants `gitea.pr.close` only for
|
||||
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
|
||||
review or author workflows.
|
||||
|
||||
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
|
||||
|
||||
## Setup runbook — interactive menu
|
||||
@@ -224,6 +255,273 @@ Legacy environment-only setups keep working unchanged until migrated.
|
||||
Each runbook names the **profile role** it runs under, the steps, and a safe
|
||||
prompt. Confirm the active profile first (`gitea_get_profile` / `gitea_whoami`).
|
||||
|
||||
## Work Selection Rule for LLMs
|
||||
|
||||
Before starting any issue or PR work, acquire or verify a work lease. Do not
|
||||
begin coding, reviewing, fixing, branching, committing, pushing, commenting,
|
||||
or creating a PR until you prove the target is not already being worked.
|
||||
|
||||
Required checks:
|
||||
|
||||
1. List open PRs.
|
||||
2. Search for PRs linked to the target issue.
|
||||
3. Search local and remote branches for the issue number.
|
||||
4. Search registered worktrees for the issue branch.
|
||||
5. Check dirty worktrees.
|
||||
6. Check active leases or recent handoffs.
|
||||
7. Check whether the issue was already completed by a merged PR.
|
||||
|
||||
If another active LLM/session owns the lease, stop. Allowed responses:
|
||||
continue as the lease owner; review the existing PR if reviewer capability
|
||||
allows; produce a handoff; request takeover after lease expiry; stop with
|
||||
"work already claimed."
|
||||
|
||||
Never create a parallel branch or PR for the same issue unless the old branch
|
||||
is proven abandoned and the takeover is recorded.
|
||||
|
||||
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
|
||||
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
|
||||
records an `author_issue_work` lease in a keyed lock file under
|
||||
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
|
||||
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
|
||||
its active lock through a per-process pointer so concurrent repos/issues never
|
||||
share one overwrite-prone slot (#443).
|
||||
|
||||
Each lock payload includes issue number, optional PR number, branch, worktree
|
||||
path, claimant identity/profile, created timestamp, expiry timestamp, and last
|
||||
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
|
||||
work. An expired lease still blocks takeover until a recovery review records why
|
||||
the prior work is abandoned, completed, or unsafe to continue.
|
||||
|
||||
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
|
||||
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
|
||||
based on another unmerged PR's branch) is an explicit, opt-in path: pass
|
||||
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
|
||||
fails closed unless that branch is owned by a live **open** PR whose number
|
||||
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
|
||||
bases. When approved, the lock payload records
|
||||
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
|
||||
be base-equivalent to that branch instead of master. `gitea_create_pr` then
|
||||
allows `base = <that branch>` only when it matches the recorded approval, the
|
||||
dependency PR is **still open**, and the PR body documents the stack:
|
||||
|
||||
- `Stacked on PR #<X> / issue #<Y>`
|
||||
- `Base branch: <feature-branch>`
|
||||
- `Head branch: <this-issue-branch>`
|
||||
- `Do not merge before PR #<X>` (merge ordering)
|
||||
- retarget/rebase to `master` after the dependency lands, if required
|
||||
|
||||
Stacked support never bypasses the issue lock — the base is recorded *on* the
|
||||
lock and re-verified at PR time. A merged/closed dependency base fails closed;
|
||||
retarget onto `master` or re-lock against a live base.
|
||||
|
||||
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
|
||||
recovery path.** That global slot is deprecated and can clobber unrelated live
|
||||
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
|
||||
adoption rebinds the session when the issue's exact branch already exists (#442).
|
||||
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
|
||||
matching `head` branch without unsafe manual seeding.
|
||||
|
||||
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
|
||||
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
|
||||
shared state and manual writes can clobber another session's live lease. Use
|
||||
`sanctioned recovery` instead:
|
||||
|
||||
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
|
||||
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
|
||||
3. Operator override only when explicitly authorized — record
|
||||
`External-state mutations` and `operator override proof` in the final report.
|
||||
|
||||
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
|
||||
adopts an existing own branch, the response carries an `adoption` block with
|
||||
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
|
||||
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
|
||||
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
|
||||
lock instead returns an `adoption_check` block with `adoption_decision`
|
||||
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
|
||||
as claiming adoption. Recovery reports should quote the live lock response
|
||||
`adoption`/`adoption_check` block directly instead of inferring adoption from
|
||||
separate offline checks.
|
||||
|
||||
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
|
||||
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
|
||||
under `External-state mutations: none` or mix author PR creation with reviewer
|
||||
approval in one run. See also #438 (global lock redesign).
|
||||
|
||||
Remote branches matching the issue number are also treated as active work unless
|
||||
the recovery review proves the branch is abandoned or superseded. Never delete
|
||||
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
|
||||
the only copy of unmerged work.
|
||||
|
||||
Full portable wording:
|
||||
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
## Global LLM Worktree Rule
|
||||
|
||||
The main project checkout is a stable control checkout. It must stay on the
|
||||
configured stable branch: `master`, `main`, or `dev`.
|
||||
|
||||
All LLM task work must happen inside the project's `branches/` directory.
|
||||
|
||||
Before any mutation, prove:
|
||||
|
||||
1. current project root
|
||||
2. current working directory
|
||||
3. current branch
|
||||
4. stable branch for the main checkout
|
||||
5. session-owned worktree path under `branches/`
|
||||
|
||||
If `cwd` is not inside `branches/`, stop. Do not edit, create, delete, format,
|
||||
test-write, commit, merge, rebase, checkout task branches, resolve conflicts,
|
||||
or run cleanup.
|
||||
|
||||
There are no exceptions for small fixes, docs, tests, cleanup, PR review fixes,
|
||||
conflict resolution, or emergencies.
|
||||
|
||||
The main checkout may only be used for read-only inspection, fetching,
|
||||
stable-branch update after merged PRs, creating `branches/` worktrees, or
|
||||
explicit control-checkout repair.
|
||||
|
||||
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
|
||||
|
||||
### Root checkout guard (#475)
|
||||
|
||||
The MCP server enforces a fail-closed **root checkout guard** before author,
|
||||
reviewer, and merger mutations when the active workspace is not an isolated
|
||||
`branches/...` worktree (reconciler close paths remain exempt per #468).
|
||||
|
||||
The guard blocks when the **control checkout** (repository root) is:
|
||||
|
||||
- on a non-stable branch (`master` / `main` / `dev` expected),
|
||||
- detached HEAD,
|
||||
- dirty (tracked edits),
|
||||
- or its `HEAD` does not match `prgs/master` when that ref is available.
|
||||
|
||||
**Remediation (never auto-reset or stash):**
|
||||
|
||||
> Root checkout is not on master. Preserve state, switch root back to master,
|
||||
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
|
||||
|
||||
**Recovery after root hijack:**
|
||||
|
||||
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
|
||||
rescue branch from a `branches/...` worktree).
|
||||
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
|
||||
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
|
||||
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
|
||||
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
|
||||
`git worktree add`.
|
||||
|
||||
`branches/...` directories are disposable role worktrees; the root checkout is
|
||||
the stable orchestration surface only.
|
||||
|
||||
## Canonical workflow skill names (#551)
|
||||
|
||||
Controller prompts and sessions must load the **same** workflow skill wall
|
||||
regardless of runtime (Claude, Codex, Gemini):
|
||||
|
||||
| Name | Role |
|
||||
|------|------|
|
||||
| `gitea-workflow` | Primary controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo package |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
- Inventory: `mcp_list_project_skills` lists all three.
|
||||
- Preflight: `mcp_check_workflow_skill_preflight` before mutations.
|
||||
- Codex install: `scripts/install-codex-workflow-skill.sh`
|
||||
- Full doc: [`docs/workflow-skill-mount.md`](workflow-skill-mount.md)
|
||||
|
||||
If the skill is missing, stop with BLOCKED + DIAGNOSE — do not mutate.
|
||||
|
||||
## No direct-import mutation path (#558)
|
||||
|
||||
Never `import gitea_mcp_server` or call `gitea_auth.get_auth_header` /
|
||||
keychain fill from a raw shell to bypass MCP preflight.
|
||||
|
||||
Use the official MCP daemon only. See [`docs/mcp-daemon-import-guard.md`](mcp-daemon-import-guard.md).
|
||||
|
||||
## Bootstrap Review Path for self-hosted MCP fixes (#557)
|
||||
|
||||
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
|
||||
runs from broken `master`, canonical review can deadlock on itself.
|
||||
|
||||
Do **not** improvise raw API, direct imports, root edits, or gate bypasses.
|
||||
|
||||
Use the narrow controller-authorized path documented in:
|
||||
|
||||
- [`docs/bootstrap-review-path.md`](bootstrap-review-path.md)
|
||||
|
||||
Hard rules:
|
||||
|
||||
- No merge without a durable `BOOTSTRAP REVIEW AUTHORIZATION (#557)` record on
|
||||
the PR or linked issue.
|
||||
- Validation, duplicate-PR, mutation-ledger, and contamination audits remain
|
||||
mandatory.
|
||||
- Root checkout stays read-only orchestration only; verification runs in
|
||||
`branches/` worktrees.
|
||||
- After land: restart MCP daemons and re-verify with canonical tools only.
|
||||
- This never weakens normal reviewer/merger gates for ordinary PRs.
|
||||
|
||||
## Shell Spawn Hard-Stop Rule
|
||||
|
||||
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
|
||||
That is an executor spawn failure, not a command failure — the command never
|
||||
ran, and retrying the identical call cannot succeed.
|
||||
|
||||
Required behavior (fail closed, issue #258):
|
||||
|
||||
1. **Probe once.** On the first spawn failure, run one trivial probe
|
||||
(`echo ok` or `pwd`). If the probe also returns `exit_code: -1`, mark
|
||||
shell unavailable for the session.
|
||||
2. **Hard-stop at two.** After two consecutive spawn failures, stop all
|
||||
further shell tool use for the session; never retry the same failing
|
||||
spawn. A hundred retries produce a hundred identical failures (session
|
||||
`019f382e`: 100+ tool calls stalled on a trivial encode-and-commit task).
|
||||
3. **Emit a recovery report.** The report must direct the operator to:
|
||||
- restart the session,
|
||||
- kill hung background terminals (a hung test runner holding the
|
||||
executor is a known contributor),
|
||||
- prefer MCP-native paths for remaining mutations (for example
|
||||
`gitea_commit_files` under `gitea.repo.commit`) instead of shell.
|
||||
4. **No improvised fallbacks.** Shell unavailability never authorizes
|
||||
WebFetch/browser/manual-encoding workarounds (see #260). No shell means
|
||||
stop-and-report.
|
||||
|
||||
Doc-contract tests: `tests/test_shell_spawn_hard_stop_docs.py`.
|
||||
|
||||
## Subagent Tool-Budget Guardrails
|
||||
|
||||
General-purpose subagents tasked with **deterministic MCP work** (for example a
|
||||
single `gitea_commit_files` call) have expanded to 100–122 tool calls,
|
||||
WebFetch/Playwright fallbacks, and throwaway helper-script generation instead of
|
||||
calling the native MCP tool once (observed during #152 closure, issue #259).
|
||||
|
||||
**Default budgets** (fail closed when exceeded):
|
||||
|
||||
| Task class | Max tool calls | Max wall time |
|
||||
|------------|----------------|---------------|
|
||||
| Single-step MCP mutation (`commit_files`, `create_pr`, `lock_issue`) | 15 | 5 minutes |
|
||||
| Review / merge queue inspection | 40 | 15 minutes |
|
||||
| Exploration / codebase search (non-mutating) | 60 | 20 minutes |
|
||||
|
||||
**Required behavior:**
|
||||
|
||||
1. **Main session first.** When the active author profile allows
|
||||
`gitea.repo.commit` and `gitea_commit_files` is visible, the main session
|
||||
must call it directly — do not delegate commit authority to a subagent
|
||||
(see #260).
|
||||
2. **Native MCP before fallback.** After a shell spawn failure (#258), attempt
|
||||
the native MCP tool once before any alternate path. Shell unavailability
|
||||
never authorizes WebFetch, Playwright, or manual base64 encoding.
|
||||
3. **No retry spirals.** Never resume a failed subagent into a larger retry
|
||||
loop or spawn a second subagent for the same deterministic step. Stop and
|
||||
emit a recovery report instead.
|
||||
4. **Forbidden detours** when `gitea_commit_files` is available: WebFetch,
|
||||
Playwright/browser automation, manual LLM-generated base64, and ad-hoc
|
||||
`_encode_*` / `_emit_*` helper scripts left in the repo.
|
||||
|
||||
Doc-contract tests: `tests/test_subagent_tool_budget_docs.py`.
|
||||
|
||||
## Branch worktree isolation
|
||||
|
||||
All LLM implementation and review work happens in an isolated branch worktree
|
||||
@@ -255,6 +553,30 @@ may edit another issue's branch folder unless explicitly assigned to that issue.
|
||||
No LLM may clean another issue's branch folder unless the PR is merged or closed
|
||||
and cleanup is explicitly part of the task.
|
||||
|
||||
## Agent temp artifact cleanup (#261)
|
||||
|
||||
Failed or aborted MCP commit attempts sometimes leave throwaway helper scripts in
|
||||
the **repository root**. These are not part of any issue scope and pollute
|
||||
`git status`, which can break `gitea_lock_issue` and preflight checks.
|
||||
|
||||
**Patterns (repo root only, untracked):**
|
||||
|
||||
- `_encode_*.py` — base64 payload encoders
|
||||
- `_emit_*.py` — commit payload emitters
|
||||
- `_inline_*.py` — inline encoding helpers
|
||||
|
||||
**Required cleanup (after MCP commit completes or aborts):**
|
||||
|
||||
1. Delete any matching files at the repo root (`rm ./_encode_*.py` etc.).
|
||||
2. Confirm `git status` is clean on the orchestration checkout before
|
||||
`gitea_lock_issue`.
|
||||
3. Prefer native `gitea_commit_files` / gated commit paths — do not leave shell
|
||||
encoding fallbacks behind.
|
||||
|
||||
Root-level matches are listed in `.gitignore` so they never get committed.
|
||||
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
|
||||
hard blocks) when these artifacts are still present.
|
||||
|
||||
Implementation work and review work must use separate branch folders. For
|
||||
example, an implementation branch might live under
|
||||
`branches/fix-issue-123-example`, while a review branch for the resulting PR
|
||||
@@ -309,24 +631,74 @@ git branch -d fix/issue-123-example
|
||||
All three helpers accept `--dry-run` to print the exact commands/paths without
|
||||
touching anything.
|
||||
|
||||
### MCP-native commit path (#260)
|
||||
|
||||
When the active author profile allows **`gitea.repo.commit`** and
|
||||
**`gitea_commit_files`** is visible in the client, that is the **only** approved
|
||||
path for committing files to the tracked repository. Do not improvise alternate
|
||||
encoding or transport when MCP commit is available.
|
||||
|
||||
**Required before commit:**
|
||||
|
||||
1. Call `gitea_resolve_task_capability` for `commit_files` or
|
||||
`gitea_commit_files` and confirm `allowed_in_current_session` is true.
|
||||
2. Use `gitea_commit_files` with file payloads prepared in the author worktree.
|
||||
3. Stage only issue-scoped paths; never commit throwaway `_encode_*` /
|
||||
`_emit_*` / `_inline_*` helpers.
|
||||
|
||||
**Explicitly forbidden workarounds** when MCP commit is reachable:
|
||||
|
||||
- `WebFetch` / HTTP calls to external decode sites (for example httpbin base64
|
||||
endpoints)
|
||||
- Playwright or other browser automation to bypass MCP
|
||||
- Manual LLM-generated base64 pasted into ad-hoc scripts
|
||||
- Delegating commit authority to a subagent while the main session has
|
||||
`gitea.repo.commit` on an author profile
|
||||
|
||||
**If shell encoding is unavailable** (spawn failure, hung terminal) **and** MCP
|
||||
commit cannot run: **stop** with a recovery report. Mention restarting the
|
||||
session, clearing hung background terminals, switching to MCP-native commit, and
|
||||
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
|
||||
loop and do **not** substitute WebFetch/Playwright/manual base64.
|
||||
|
||||
### Terminal launcher diagnostics (#556)
|
||||
|
||||
When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`),
|
||||
do **not** improvise shell wrappers or fall back to direct API / temp scripts.
|
||||
|
||||
1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns
|
||||
categorized failure: missing cwd, cwd not a directory, missing executable,
|
||||
missing runtime wrapper, missing shell, probe timeout, or session launcher
|
||||
failure.
|
||||
2. Call `gitea_get_shell_health` for the shell circuit-breaker state.
|
||||
3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher
|
||||
before allowing git/pytest-oriented work; on failure it returns
|
||||
`BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics.
|
||||
4. Emit the canonical `blocked-diagnose-report.md` template and stop.
|
||||
|
||||
### Create an issue / child issues
|
||||
|
||||
- **Profile:** issue-manager or author (any profile allowed to create issues).
|
||||
- **Steps:** create the parent/roadmap issue; create child issues; apply the
|
||||
minimal label set; link children to the parent.
|
||||
- **Labels:** new issues should carry one `type:*` label and one `status:*`
|
||||
label. Discussion-only issues must carry `type:discussion`. See
|
||||
[`label-taxonomy.md`](label-taxonomy.md).
|
||||
- **Prompt:** `Using the issue-manager profile, create issue "<title>" with body
|
||||
<body>, then create child issues for <list> and link them to the parent.`
|
||||
|
||||
### Implement an issue and open a PR
|
||||
|
||||
- **Profile:** author.
|
||||
- **Steps:** claim the issue (`status:in-progress`); create an isolated branch
|
||||
worktree from latest `master` under `branches/` (`feat/issue-<n>-...` /
|
||||
- **Steps:** claim the issue (`status:in-progress`, replacing any old
|
||||
`status:*` label); create an isolated branch worktree from latest `master`
|
||||
under `branches/` (`feat/issue-<n>-...` /
|
||||
`fix/...` / `docs/...`); `cd` into that worktree; implement narrowly; add or
|
||||
update tests if behavior changes; run the full suite; commit with an
|
||||
issue-linked message; open a PR to `master`. **Do not** review or merge your
|
||||
own PR. Include an `LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in
|
||||
the PR body — see [`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
issue-linked message; open a PR to `master`; move the issue to
|
||||
`status:pr-open`. **Do not** review or merge your own PR. Include an
|
||||
`LLM Handoff Metadata` block (with `LLM-Agent-SHA`) in the PR body — see
|
||||
[`llm-agent-sha.md`](llm-agent-sha.md).
|
||||
- **Prompt:** `Use an author profile to implement issue #N and open a PR to
|
||||
master. Do not self-review or self-merge.`
|
||||
|
||||
@@ -367,13 +739,40 @@ touching anything.
|
||||
### Merge a PR
|
||||
|
||||
- **Profile:** merger (allowed to merge; must **not** be the PR author).
|
||||
- **Steps:** confirm eligibility; require explicit confirmation
|
||||
(`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when
|
||||
Gitea reports the PR mergeable (branch-protection checks satisfied). No force,
|
||||
no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- **Steps:**
|
||||
- Merger workflow starts only after formal approval at current head. Reviewer workflow ends with review decision and separate merger handoff.
|
||||
- Confirm eligibility; require explicit confirmation (`MERGE PR <n>`); optionally pin head SHA / changed-file set; merge only when Gitea reports the PR mergeable (branch-protection checks satisfied). No force, no ignore-checks. Verify that remote master contains the merge commit or the expected squashed changes (do not assume a "closed" PR succeeded without verifying the actual landed changes).
|
||||
- Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
|
||||
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
|
||||
|
||||
#### Merger lease adoption (#536)
|
||||
|
||||
When review and merge run in **separate sessions**, the merger must **not**
|
||||
manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state.
|
||||
That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not
|
||||
canonical proof and is rejected by mutation gates.
|
||||
|
||||
**Canonical merger handoff:**
|
||||
|
||||
1. Confirm a reviewer session holds an active PR lease and posted **APPROVED**
|
||||
at the current live head (`gitea_get_pr_review_feedback`).
|
||||
2. In a clean merger worktree under `branches/`, call
|
||||
`gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and
|
||||
optional `issue_number`.
|
||||
3. The tool posts durable adoption proof on the PR thread (`<!-- mcp-review-lease-adoption:v1 -->`)
|
||||
recording actor, profiles, adopted-from session/comment, adoption reason, and
|
||||
timestamp, then records sanctioned in-session provenance.
|
||||
4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`.
|
||||
|
||||
**Forbidden:** Python one-liners or scripts that call `record_session_lease()`
|
||||
without provenance from `gitea_acquire_reviewer_pr_lease`,
|
||||
`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`.
|
||||
|
||||
**Same-session review+merge:** the reviewer session may use
|
||||
`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session
|
||||
merger handoff.
|
||||
|
||||
### Close the issue after merge / Reconciliation
|
||||
|
||||
- **Profile:** issue-manager or merger.
|
||||
@@ -391,6 +790,53 @@ touching anything.
|
||||
- **Prompt (normal):** `After verifying master contains the merge of PR #N using post-merge file-presence verification, close issue #M and delete the merged branch. Include verification details in the report.`
|
||||
- **Prompt (reconcile):** `Reconcile closed-not-merged PR #N by verifying if its content landed on master.`
|
||||
|
||||
### Post-merge merged cleanup ownership (#523)
|
||||
|
||||
Post-merge **local worktree / remote branch cleanup** is **reconciler** work, not
|
||||
author work. Do not switch from `prgs-reconciler` to `prgs-author` only to run
|
||||
`gitea_reconcile_merged_cleanups`.
|
||||
|
||||
- **Profile:** `prgs-reconciler` (task `reconcile_merged_cleanups` /
|
||||
`reconciliation_cleanup`).
|
||||
- **Namespace:** reconciler MCP server; stable control checkout is allowed for
|
||||
this role (branches-only author guard does not apply).
|
||||
- **Steps:**
|
||||
1. `gitea_whoami` + `gitea_resolve_task_capability(task="reconcile_merged_cleanups")`.
|
||||
2. Dry-run first: `gitea_reconcile_merged_cleanups(dry_run=True)`.
|
||||
3. Execute only after audit/authorization gates when remote branch delete or
|
||||
worktree removal is required (`dry_run=False`, `execute_confirmed=True`,
|
||||
and `gitea.branch.delete` when deleting remotes).
|
||||
- **Fail closed:** unmerged/open heads, mismatched worktrees, and non-merged
|
||||
closed PRs must not be cleaned.
|
||||
- **Reports:** label cleanup actions as reconciler cleanup (not author mutation).
|
||||
- **Prompt:** `As prgs-reconciler, dry-run then execute gitea_reconcile_merged_cleanups for recently merged PRs without switching to prgs-author.`
|
||||
|
||||
### Superseded PR / satisfied issue reconciliation (#525)
|
||||
|
||||
Closing duplicate or superseded PRs after a canonical PR has merged is
|
||||
**reconciler** work. Do not switch to `prgs-author` only to close the duplicate
|
||||
PR, close the satisfied issue, or file a narrow follow-up discovered during
|
||||
reconciliation.
|
||||
|
||||
- **Profile:** `prgs-reconciler`.
|
||||
- **Tasks:** `reconcile_close_superseded_pr`,
|
||||
`reconcile_close_satisfied_issue`, and
|
||||
`reconcile_create_followup_issue`.
|
||||
- **Tool:** `gitea_reconcile_superseded_by_merged_pr`.
|
||||
- **Required proof:**
|
||||
1. Live target PR state is open, but it is not independently required and no
|
||||
mergeable work remains.
|
||||
2. Live superseding PR state is closed and merged.
|
||||
3. Superseding PR head is an ancestor of freshly fetched `master`.
|
||||
4. Merge commit SHA is recorded.
|
||||
5. Canonical close comment cites the superseding PR and merge commit.
|
||||
6. Linked issue closure is attempted only when the issue is open and
|
||||
explicitly satisfied by the superseding PR.
|
||||
- **Fail closed:** missing ancestry proof, missing canonical comment,
|
||||
mergeable target PR, same target/superseding PR, or missing
|
||||
`gitea.pr.close` / `gitea.issue.close` capability.
|
||||
- **Prompt:** `As prgs-reconciler, reconcile PR #N as superseded by merged PR #M; post the canonical close comment, close the superseded PR, and close issue #K only if the merged PR fully satisfies it.`
|
||||
|
||||
### Stop on blocker
|
||||
|
||||
- **Any profile.** If a required gate cannot be satisfied — identity
|
||||
@@ -411,7 +857,7 @@ an author.
|
||||
|---|---|---|---|---|
|
||||
| Review PR (`review_pr`) | reviewer (e.g. `sysadmin` / `prgs-reviewer`) | read, gated review verdicts | commits, pushes, file edits, author comments, merge without eligibility | active profile is an author profile — stop immediately; do **not** switch to author-side fixes unless the operator explicitly re-tasks |
|
||||
| Address PR change requests (`address_pr_change_requests`) | author (e.g. `jcwalker3` / `prgs-author`) | commit/push fixes to the PR branch, PR comment summarizing fixes | review verdicts, approve, request-changes, merge | active profile lacks branch push |
|
||||
| Merge PR (`merge_pr`) | reviewer/merger | gated merge after eligibility + approval | merging own PR, merging without pinned head match | active profile is an author profile, or any merge gate fails |
|
||||
| Merge PR (`merge_pr`) | merger (e.g. `sysadmin` / `prgs-merger`) | gated merge after eligibility + approval | merging own PR, merging without pinned head match, reviewing PRs | active profile is an author or reviewer-only profile, or any merge gate fails |
|
||||
| Comment on issue discussion (`comment_issue`) | any profile with `gitea.issue.comment` | issue thread comments | review verdicts, closing via comment | permission missing (`gitea.pr.comment` does **not** imply it) |
|
||||
| Comment on PR (`comment_pr`) | any profile with `gitea.pr.comment` | PR thread comments | review verdicts | permission missing |
|
||||
| Author implementation (`create_branch`/`push_branch`/`create_pr`) | author | branch, commit, push, open PR | self-review, self-merge | profile lacks the author permissions |
|
||||
@@ -461,6 +907,27 @@ Never imply full-suite success unless the full-suite command itself passed
|
||||
(`full_suite_passed: true`). A report that hides a failed or skipped check
|
||||
is worse than a failing report.
|
||||
|
||||
## Canonical Thread Handoff (CTH)
|
||||
|
||||
**CTH** = **Canonical Thread Handoff** — the authoritative workflow handoff
|
||||
comment in a Gitea issue or PR thread. See
|
||||
[`canonical-thread-handoff.md`](canonical-thread-handoff.md) for types,
|
||||
templates, and examples.
|
||||
|
||||
Before acting in an issue or PR thread:
|
||||
|
||||
1. **Find the latest CTH comment** before doing work.
|
||||
2. Treat the **latest valid CTH** as the current handoff state.
|
||||
3. **Post a new CTH** when you finish, block, skip, supersede, request
|
||||
changes, approve, or hand off.
|
||||
4. Do **not** rely on stale non-CTH comments when a newer CTH exists.
|
||||
|
||||
A CTH summarizes workflow state for the next session. Formal Gitea review
|
||||
verdicts remain authoritative for merge gates — a CTH is not merge approval
|
||||
by itself.
|
||||
|
||||
Template: [`../skills/llm-project-workflow/templates/canonical-thread-handoff.md`](../skills/llm-project-workflow/templates/canonical-thread-handoff.md)
|
||||
|
||||
## Controller Handoff (required, every task)
|
||||
|
||||
Every task — implementation, review, merge, triage, documentation,
|
||||
@@ -493,6 +960,161 @@ touched release state names the exact tag/commit and why. Design debates
|
||||
belong in **discussion/RFC issues** (e.g. #100 `profiles.json v2`) — comment
|
||||
on the issue, create no branches/PRs, and end the comment with this handoff.
|
||||
|
||||
## Two-comment workflow reporting (#507)
|
||||
|
||||
After meaningful controller/workflow work, post **two separate Gitea comments**
|
||||
(not one combined blob):
|
||||
|
||||
1. **`[CONTROLLER HANDOFF]`** — detailed operational continuation for the
|
||||
next LLM/controller (proof-heavy; may be long).
|
||||
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||
|
||||
The ledger must answer: what is true now, what changed, what is blocked,
|
||||
who/what acts next — and must **separate**:
|
||||
|
||||
- local verdict/state
|
||||
- server-side Gitea state
|
||||
- attempted-but-blocked mutations
|
||||
- completed mutations
|
||||
|
||||
Use precise state phrases (`APPROVED review posted to Gitea`,
|
||||
`APPROVE verdict prepared locally`, `merge performed`, `merge not performed`,
|
||||
`no server-side state changed`, `lease attempt blocked`) instead of ambiguous
|
||||
standalone words (`approved`, `merged`, `ready`, `blocked`, `done`).
|
||||
|
||||
The ledger must include a **blocker classification** from:
|
||||
`code blocker`, `test blocker`, `merge conflict`, `stale head`,
|
||||
`permission/capability blocker`, `environment/tooling blocker`,
|
||||
`process/rule blocker`, `queue/lease blocker`,
|
||||
`duplicate/canonicalization blocker`, `no blocker`.
|
||||
|
||||
Templates: [`two-comment-workflow.md`](two-comment-workflow.md).
|
||||
Worked examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md).
|
||||
|
||||
Validation: `thread_state_ledger_validator.py` checks tagged comments at post
|
||||
time (`gitea_create_issue_comment`) and tagged final reports via
|
||||
`assess_final_report_validator`. Legacy `## Controller Handoff` final reports
|
||||
remain valid during transition; the tagged pair is required for new workflow
|
||||
comments.
|
||||
|
||||
Related (do not duplicate): #494/#495 lifecycle state, #501 mutation-ledger
|
||||
consistency, #505 CTH umbrella, #496 workflow comment gate when merged.
|
||||
|
||||
## Canonical comment validation (#496)
|
||||
|
||||
Workflow-changing issue/PR/review comments must carry durable next-action
|
||||
state. Casual discussion is still allowed.
|
||||
|
||||
The MCP server runs `canonical_comment_validator.assess_canonical_comment`
|
||||
**before** posting through:
|
||||
|
||||
- `gitea_create_issue_comment`
|
||||
- `gitea_submit_pr_review` / `gitea_dry_run_pr_review` (non-empty review bodies)
|
||||
- `gitea_reconcile_already_landed_pr` when `post_comment=True`
|
||||
- internal structured comment helpers (machine lease/heartbeat markers stay exempt)
|
||||
|
||||
Detection examples:
|
||||
|
||||
- **Allowed:** `Thanks, I will check this.`
|
||||
- **Rejected:** `Blocked, author should fix.` (workflow trigger without canonical fields)
|
||||
|
||||
When validation fails, the tool returns `canonical_comment_validation` with
|
||||
`allowed: false`, `missing_fields`, `vague_fields`, `correction_message`, and
|
||||
`suggested_template`. **No Gitea API call is made.**
|
||||
|
||||
Minimum workflow comment fields:
|
||||
|
||||
```text
|
||||
STATE:
|
||||
WHO_IS_NEXT:
|
||||
NEXT_ACTION:
|
||||
NEXT_PROMPT:
|
||||
WHY:
|
||||
```
|
||||
|
||||
`WHO_IS_NEXT` must be one of: `controller`, `author`, `reviewer`, `merger`,
|
||||
`reconciler`, `user`.
|
||||
|
||||
Issue comments also require `RELATED_PRS`, `BLOCKERS`, and `VALIDATION` when
|
||||
they mention PR work. PR comments/reviews also require `ISSUE`, `HEAD_SHA`,
|
||||
`REVIEW_STATUS`, `MERGE_READY`, `BLOCKERS`, and `VALIDATION`.
|
||||
|
||||
Special states:
|
||||
|
||||
- `STATE: blocked` — `BLOCKERS` must name an explicit unblock condition.
|
||||
- `STATE: superseded` — requires `CANONICAL_ITEM` and `SUPERSEDED_ITEM`.
|
||||
- `STATE: ready-to-merge` — requires approval proof and head SHA in
|
||||
`HEAD_SHA`, `REVIEW_STATUS`, `MERGE_READY`, or `VALIDATION`.
|
||||
|
||||
Final reports must not claim a comment was posted when
|
||||
`canonical_comment_validation.allowed` is false (#496 AC14).
|
||||
|
||||
## Stale #332 review-decision lock cleanup (#594)
|
||||
|
||||
#332 hard-stops a reviewer session after a terminal live review mutation
|
||||
(`approve` / `request_changes`). After #559 those locks are **durable** under
|
||||
`~/.cache/gitea-tools/session-state/review_decision_lock-<profile>.json`, so they
|
||||
can outlive the PR they protected.
|
||||
|
||||
### When cleanup is **allowed**
|
||||
|
||||
Use `gitea_cleanup_stale_review_decision_lock` from a **reviewer** profile when:
|
||||
|
||||
1. A durable review-decision lock has a terminal live mutation, **and**
|
||||
2. Live Gitea state shows that terminal PR is already **merged** or **closed**
|
||||
(so no same-PR merge sequence remains), **and**
|
||||
3. The active profile identity matches the lock, **and**
|
||||
4. Authenticated identity can be verified.
|
||||
|
||||
Workflow:
|
||||
|
||||
```text
|
||||
# 1) Assess only (default)
|
||||
gitea_cleanup_stale_review_decision_lock(apply=false, remote=prgs, ...)
|
||||
|
||||
# 2) Apply after is_moot=true and cleanup_allowed=true
|
||||
gitea_cleanup_stale_review_decision_lock(
|
||||
apply=true,
|
||||
expected_terminal_pr=<merged-or-closed-pr>,
|
||||
remote=prgs,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
Successful apply:
|
||||
|
||||
* clears in-memory + durable decision lock for that profile
|
||||
* returns a structured `audit` payload
|
||||
* posts an audit comment on the mooted PR when `gitea.pr.comment` is allowed
|
||||
(`post_audit_comment=true` default)
|
||||
|
||||
Same-profile auto-expire: if `gitea_merge_pr` succeeds on a PR that this same
|
||||
profile just approved, the decision lock is cleared after merge. Cross-profile
|
||||
locks (reviewer vs merger) still require the cleanup tool.
|
||||
|
||||
### When cleanup is **forbidden**
|
||||
|
||||
Do **not** clear when:
|
||||
|
||||
* the last terminal PR is still **open** (active #332 hard-stop — continue merge
|
||||
for that approve, or stop after request_changes)
|
||||
* live PR state cannot be fetched (fail closed)
|
||||
* profile identity does not match the lock
|
||||
* identity cannot be verified
|
||||
* `expected_terminal_pr` does not match the last terminal PR
|
||||
|
||||
**Never** use manual deletion of session-state files as the normal workflow.
|
||||
**Never** use cleanup to skip review of an open PR or to bypass eligibility /
|
||||
mark-ready / submit gates on active work.
|
||||
|
||||
### Related tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `gitea_cleanup_stale_review_decision_lock` | Moot decision-lock cleanup (#594) |
|
||||
| `gitea_authorize_review_correction` | Operator-approved correction after a **mistaken** live review on still-active work (#211) |
|
||||
| `gitea_cleanup_post_merge_moot_lease` | Moot **lease** cleanup after merge (#515) — different object |
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
Before any mutating action the workflow verifies identity, active profile,
|
||||
@@ -556,6 +1178,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
|
||||
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
```
|
||||
|
||||
## Namespace workspace binding (#510)
|
||||
|
||||
Each MCP namespace resolves its **own** active task workspace. Foreign role
|
||||
worktree environment variables must not poison another namespace's purity
|
||||
checks.
|
||||
|
||||
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|
||||
|-----------|------------------------------------------------------------------|---------------|
|
||||
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
|
||||
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
|
||||
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
|
||||
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
|
||||
|
||||
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
|
||||
MCP processes ignore it even when it points at a dirty author WIP tree.
|
||||
|
||||
### Safe reconnect / rebind procedure
|
||||
|
||||
When a mutation blocks on workspace binding:
|
||||
|
||||
1. Read the error — it names the **resolved workspace path**, **role
|
||||
namespace**, and **binding source** (tool arg, env var, or process root).
|
||||
2. Reconnect or relaunch the correct namespace MCP server from the intended
|
||||
workspace (or set the role-specific env var before launch).
|
||||
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
|
||||
branches/ worktree differs from the MCP process root.
|
||||
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
|
||||
own namespace — that destroys another agent's WIP.
|
||||
|
||||
### CTH guidance for workspace binding blockers
|
||||
|
||||
When posting a Canonical Thread Handoff after a binding blocker:
|
||||
|
||||
- State which namespace was active (author / reviewer / merger / reconciler).
|
||||
- Quote the resolved workspace path and binding source from the error.
|
||||
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
|
||||
`GITEA_*_WORKTREE`, or pass `worktree_path`).
|
||||
- Explicitly note that foreign worktrees must not be cleaned to unblock.
|
||||
|
||||
## Safety notes
|
||||
|
||||
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
|
||||
@@ -565,6 +1226,8 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
|
||||
## Related documents
|
||||
|
||||
- [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501).
|
||||
- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500).
|
||||
- [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill.
|
||||
- [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model.
|
||||
- [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision).
|
||||
@@ -574,3 +1237,37 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
|
||||
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
|
||||
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
|
||||
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
|
||||
- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494).
|
||||
|
||||
## Canonical state handoff ledger (#494)
|
||||
|
||||
Gitea comments and final reports must make continuation obvious without chat
|
||||
history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for:
|
||||
|
||||
- discussion → issue → PR → review → merge → reconcile lifecycle
|
||||
- templates for discussion, issue, PR, and queue-controller state comments
|
||||
- final-report requirements: Current status, Next actor, Next action, Next prompt
|
||||
- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial
|
||||
exceptions)
|
||||
|
||||
Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired
|
||||
through `assess_final_report_validator`.
|
||||
|
||||
## PR-only queue cleanup mode (#390)
|
||||
|
||||
Use `pr-queue-cleanup` mode when the queue holds many open PRs and the goal
|
||||
is to drain reviews deterministically. One run = one PR = one canonical
|
||||
review (`skills/llm-project-workflow/workflows/pr-queue-cleanup.md`).
|
||||
|
||||
* Cleanup runs are reviewer-role only (`pr_queue_cleanup` resolver task);
|
||||
author sessions get `wrong_role_stop`.
|
||||
* Forbidden in cleanup mode: issue claiming, branch creation, implementation
|
||||
edits, new issue filing, and any second-PR review after a terminal
|
||||
mutation.
|
||||
* Terminal chain: stop after `REQUEST_CHANGES`; after `APPROVED` continue
|
||||
only to same-PR merge with explicit per-PR operator authorization and
|
||||
passing gates; stop after merge or merge blocker.
|
||||
* Every run reports the next suggested PR without continuing to it; the next
|
||||
PR requires a fresh run with fresh identity/capability/inventory proof.
|
||||
* Use full `review-merge-pr` mode instead when the operator wants a single
|
||||
targeted review, and `work-issue` mode for any authoring.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# MCP Client Registration for External Control Plane Servers
|
||||
|
||||
Issue #151 fixes the registration and naming contract for the Jenkins and
|
||||
GlitchTip MCP servers that live outside this Gitea MCP runtime.
|
||||
|
||||
## Canonical Server Names
|
||||
|
||||
Use these exact MCP server names in clients:
|
||||
|
||||
| Server name | Boundary | Default capability |
|
||||
|---|---|---|
|
||||
| `jenkins-mcp` | Jenkins CI inspection (read) | Read-only build/job inspection |
|
||||
| `jenkins-write-mcp` | Jenkins build trigger (write) | Gated `jenkins_trigger_build` only |
|
||||
| `glitchtip-mcp` | GlitchTip observability inspection | Read-only issue/event inspection |
|
||||
|
||||
The write boundary (`jenkins-write-mcp`) is **not** registered by default (#152).
|
||||
It exposes a single mutating tool and requires operator approval of a dedicated
|
||||
trigger profile before any client config references it.
|
||||
|
||||
Historical names such as `jenkins-readonly` and `glitchtip-readonly` are
|
||||
descriptive profile labels only. They are not the canonical MCP server names
|
||||
unless an operator intentionally creates aliases and documents them.
|
||||
|
||||
## Registration Pattern
|
||||
|
||||
Register each external server as its own MCP entry. Do not add Jenkins or
|
||||
GlitchTip credentials to the Gitea MCP server. Also, do not add Gitea write
|
||||
credentials to the GlitchTip server.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jenkins-mcp": {
|
||||
"command": "/path/to/mcp-control-plane/venv/bin/python3",
|
||||
"args": ["-m", "jenkins_mcp"],
|
||||
"env": {
|
||||
"JENKINS_MCP_CONFIG": "/path/to/mcp-control-plane/profiles.json",
|
||||
"JENKINS_MCP_PROFILE": "jenkins-readonly"
|
||||
}
|
||||
},
|
||||
"glitchtip-mcp": {
|
||||
"command": "/path/to/mcp-control-plane/venv/bin/python3",
|
||||
"args": ["-m", "glitchtip_mcp"],
|
||||
"env": {
|
||||
"GLITCHTIP_MCP_CONFIG": "/path/to/mcp-control-plane/profiles.json",
|
||||
"GLITCHTIP_MCP_PROFILE": "glitchtip-readonly"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Client-specific wrappers may differ, but the server names, trust boundaries,
|
||||
and profile separation must remain the same. After adding or changing either
|
||||
entry, reconnect or reload the MCP client before claiming the tools are usable.
|
||||
|
||||
## Discoverability Validation
|
||||
|
||||
Before using either server in a task, prove the expected tools are visible in
|
||||
the client. It is not enough for the config entry to exist.
|
||||
|
||||
Expected Jenkins read tools (`jenkins-mcp` only):
|
||||
|
||||
- `jenkins_whoami`
|
||||
- `jenkins_list_jobs`
|
||||
- `jenkins_latest_build`
|
||||
- `jenkins_build_status`
|
||||
- `jenkins_get_build`
|
||||
|
||||
`jenkins_trigger_build` must **not** appear on `jenkins-mcp`. When an operator
|
||||
explicitly enables the write boundary, the only expected tool on
|
||||
`jenkins-write-mcp` is `jenkins_trigger_build`.
|
||||
|
||||
Expected GlitchTip tools:
|
||||
|
||||
- `glitchtip_whoami`
|
||||
- `glitchtip_list_projects`
|
||||
- `glitchtip_list_unresolved`
|
||||
- `glitchtip_get_issue`
|
||||
- `glitchtip_recent_events`
|
||||
- `glitchtip_search`
|
||||
|
||||
If a client reports the server as enabled but exposes no usable tools, report
|
||||
`SKIPPED: server enabled but no usable tools visible`, then stop. Do not fall
|
||||
back to shell commands, raw service APIs, or unrelated MCP servers.
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
- `jenkins-mcp` read profiles must not expose build trigger tools.
|
||||
- Build triggers live on the separate `jenkins-write-mcp` server
|
||||
(`jenkins_mcp.write_server`), not on `jenkins-mcp`.
|
||||
- Jenkins build triggers require a dedicated trigger profile with
|
||||
`jenkins.build.trigger` allowed, exact confirmation
|
||||
(`TRIGGER BUILD <job-path>`), and fail-closed mutation audit.
|
||||
- Do not register `jenkins-write-mcp` until an operator approves a trigger
|
||||
profile; no shipped profile carries trigger capability by default.
|
||||
- `glitchtip-mcp` remains read-only. It must not file or mutate Gitea issues.
|
||||
- GlitchTip-to-Gitea filing is a separate library-only orchestrator in
|
||||
`mcp-control-plane` that composes the real GlitchTip read path with Gitea
|
||||
issue-write tools.
|
||||
- The filing orchestrator must run GlitchTip/Gitea dedup before any Gitea
|
||||
create action, and its create/link/skip decisions must be covered by tests.
|
||||
- The filing orchestrator must fail closed on mutation-audit failure before
|
||||
any Gitea create, link, or comment mutation.
|
||||
- If filing is ever MCP-exposed, it must use a separate write-boundary
|
||||
server/profile. It must not be exposed by `glitchtip-mcp`.
|
||||
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
|
||||
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
|
||||
@@ -0,0 +1,92 @@
|
||||
# MCP daemon import and native-transport guard (#558 / #695)
|
||||
|
||||
## Problem
|
||||
|
||||
During deadlock debugging and the PR #694 incident (#695), agents imported
|
||||
`gitea_mcp_server` or ran credential helpers from a raw shell / offline helper,
|
||||
bypassing native MCP transport, preflight purity, and role gates. Contaminated
|
||||
formal reviews then looked identical to native approvals.
|
||||
|
||||
## Rule
|
||||
|
||||
Mutation auth, keychain fill, and controller quarantine require a **production
|
||||
native MCP transport runtime** established only by:
|
||||
|
||||
1. the **resolved absolute path** of the canonical entrypoint
|
||||
(`mcp_server.py` / `gitea_mcp_server.py` next to `mcp_daemon_guard.py`), and
|
||||
2. a live **transport bind** (`bind_native_mcp_transport(transport="stdio")`)
|
||||
immediately before `mcp.run`.
|
||||
|
||||
Basename-only trust (a renamed file called `mcp_server.py`), caller-controlled
|
||||
flags (there is **no** `allow_test_bootstrap`), environment variables, stack
|
||||
frame spoofing, or import-only launch are insufficient.
|
||||
|
||||
| Context | Allowed |
|
||||
|---------|---------|
|
||||
| Official IDE-native MCP: resolved canonical entrypoint marks + binds stdio, holds process-local runtime token | yes |
|
||||
| pytest (hermetic unit tests) via `is_pytest_runtime()` | yes for unit gates |
|
||||
| `install_test_native_runtime()` under pytest (test-mode record) | unit-test transport gates only — **never** production Gitea mutations |
|
||||
| `allow_test_bootstrap=True` (removed; must not exist) | **no** |
|
||||
| Renamed runner basename `mcp_server.py` outside package root | **no** |
|
||||
| Import/launch of real entrypoint without transport bind | **no** |
|
||||
| `GITEA_MCP_SANCTIONED_DAEMON=1` alone (no process-local native runtime) | **no** (#695) |
|
||||
| `GITEA_ALLOW_DIRECT_MCP_IMPORT=1` in LLM sessions | **no** — never set; never authorizes mutations (#695 AC1 / PR #701) |
|
||||
| Override `GITEA_MCP_SESSION_STATE_DIR` mid-session | **no** — production bind pins state root; redirect cannot forge independent decision locks (#695 AC2 / PR #701) |
|
||||
| `GITEA_ALLOW_KEYCHAIN_CLI=1` in LLM sessions | **no** — human operator only |
|
||||
| bare `python -c 'import gitea_mcp_server; …'` or offline runners | **no** |
|
||||
| keychain fill outside native/pytest | **no** |
|
||||
|
||||
Native runtime is **process-local**: a random token bound to the daemon PID and
|
||||
transport phase. It is never reconstructed from environment variables,
|
||||
session-state files, caller-controlled flags, or importing internals in a
|
||||
fresh Python process.
|
||||
|
||||
## Contaminated review quarantine (#695 AC8)
|
||||
|
||||
Controller/reconciler/merger profiles may call
|
||||
`gitea_quarantine_contaminated_review` with explicit confirmation:
|
||||
|
||||
```text
|
||||
QUARANTINE CONTAMINATED REVIEW <review_id> PR <pr_number>
|
||||
```
|
||||
|
||||
Quarantine records are durable under the MCP session-state root and are
|
||||
**honored** by:
|
||||
|
||||
- `gitea_get_pr_review_feedback` (quarantined approvals do not authorize merge)
|
||||
- `gitea_check_pr_eligibility` action=`merge`
|
||||
- `gitea_merge_pr` (mutation)
|
||||
- merger lease adoption paths that read `approval_at_current_head`
|
||||
|
||||
Forensic Gitea reviews and historical comments are **never deleted**.
|
||||
|
||||
## STOP after native MCP failure (AC10)
|
||||
|
||||
If the native MCP namespace dies (EOF, capability disconnect, session death):
|
||||
|
||||
1. **STOP.** State BLOCKED + DIAGNOSE.
|
||||
2. Do **not** import `gitea_mcp_server` from a standalone process.
|
||||
3. Do **not** run `offline_mcp_helper.py`, `offline_mcp_runner.py`,
|
||||
`run_quarantine.py`, or any offline mutation helper.
|
||||
4. Do **not** set direct-import, keychain-bypass, or raw-token environment
|
||||
variables.
|
||||
5. Reconnect / restart the official MCP daemon; resume only via native tools.
|
||||
|
||||
Any further native MCP failure is a hard stop. Do not construct another fallback.
|
||||
|
||||
## Canonical approval claims (AC7)
|
||||
|
||||
Comments that claim `approved` / `ready-to-merge` / `WHO_IS_NEXT: merger` /
|
||||
`MERGE_READY: true` must include:
|
||||
|
||||
```text
|
||||
NATIVE_REVIEW_PROOF: transport=native_mcp; …
|
||||
```
|
||||
|
||||
Claims that cite offline/import helpers are rejected even if a proof line is
|
||||
present.
|
||||
|
||||
## Operator note
|
||||
|
||||
LLM sessions must never set allow-direct-import, allow-keychain-cli, or raw
|
||||
token overrides. Those are human-only escape hatches outside agent workflows.
|
||||
@@ -0,0 +1,67 @@
|
||||
# MCP operator shell menu
|
||||
|
||||
## Purpose
|
||||
|
||||
`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating
|
||||
the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path,
|
||||
or runbook section.
|
||||
|
||||
It is intentionally **safe by default**: status checks and copy-paste workflow
|
||||
prompts. It does not delete branches, force-push, edit lock files, or bypass
|
||||
sanctioned MCP tools.
|
||||
|
||||
## How to run
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
./mcp-menu.sh
|
||||
```
|
||||
|
||||
The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with
|
||||
`set -euo pipefail`.
|
||||
|
||||
## Safety rules
|
||||
|
||||
- **Read-only by default** — root checkout health is inspection only.
|
||||
- **No destructive git** — no `git push --force`, branch deletion, or
|
||||
`--delete` refspecs.
|
||||
- **No lock-file editing** — issue locks are acquired only through
|
||||
`gitea_lock_issue`.
|
||||
- **No raw API bypass** — prompts direct operators to sanctioned MCP tools.
|
||||
- **Remote mutations require confirmation** — any future menu action that would
|
||||
mutate remote or server state must be clearly labeled and require explicit
|
||||
operator confirmation before running.
|
||||
- **Author work stays under `branches/`** — the root checkout is a stable
|
||||
control checkout on `master` / `prgs/master`.
|
||||
|
||||
## Menu options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. |
|
||||
| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. |
|
||||
| Reviewer workflow prompts | Standard PR review prompt, and a skip-already-reviewed-stale-`REQUEST_CHANGES` prompt that hands off to the author without a duplicate terminal mutation (review-only; no merge). |
|
||||
| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). |
|
||||
| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. |
|
||||
| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. |
|
||||
| Proxmox deployment placeholder | **Not implemented** — informational message only. |
|
||||
| Create Proxmox LXC placeholder | **Not implemented** — informational message only. |
|
||||
| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. |
|
||||
| Exit | Quit the menu. |
|
||||
|
||||
## Placeholder-only entries
|
||||
|
||||
**Proxmox deployment** and **Create Proxmox LXC** are placeholders until
|
||||
dedicated issues implement sanctioned automation. The menu prints a clear
|
||||
message and does not invoke deploy scripts.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks
|
||||
- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill
|
||||
- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows
|
||||
|
||||
## Tests
|
||||
|
||||
Hermetic coverage lives in `tests/test_mcp_menu_script.py`.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
|
||||
|
||||
## Symptom
|
||||
|
||||
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
|
||||
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
|
||||
|
||||
```
|
||||
client is closing: EOF
|
||||
```
|
||||
|
||||
Every subsequent call to that same namespace returns the same error, including
|
||||
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
|
||||
servers registered with the same client (for example `context7`) keep working,
|
||||
so this is **not** a global MCP-client outage.
|
||||
|
||||
## Why this is not a code defect
|
||||
|
||||
This failure is a **transport-level** condition in the IDE / MCP client manager,
|
||||
not a missing or broken tool:
|
||||
|
||||
- The tool can be present and registered in the Python `FastMCP` tool manager.
|
||||
- Direct Python inspection of the server confirms the tool exists.
|
||||
- Running the server manually and sending JSON-RPC over stdio works fine
|
||||
(offline spawn) — that path does **not** prove the IDE namespace is healthy.
|
||||
|
||||
The client manager entered a closed state after the backing subprocess for that
|
||||
namespace terminated (or was killed) behind its back. Once closed, the client
|
||||
does **not** re-spawn the child on the next tool call — it just replays
|
||||
`client is closing: EOF`. The OS process may even still be alive if a parent
|
||||
language-server process is holding the stdio pipes open.
|
||||
|
||||
This is the canonical "registered in FastMCP ≠ callable through the namespace"
|
||||
false-ready state. It is distinct from the **stale-runtime** family in #531 /
|
||||
#544, where the process is reachable but running behind `master`; that case is
|
||||
detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in
|
||||
`gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one,
|
||||
so the `ps` check alone will not surface it.
|
||||
|
||||
## Recovery path (canonical — client reconnect only)
|
||||
|
||||
Do the steps in order. Stop as soon as a live **client-namespace** call succeeds.
|
||||
|
||||
1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace
|
||||
(`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a
|
||||
different MCP server (e.g. `context7`).
|
||||
- Only the Gitea namespace fails → single-namespace transport close. Continue.
|
||||
- Every server fails → restart the whole MCP client, not just one namespace.
|
||||
|
||||
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
|
||||
client MCP-reconnect action for that server entry (in Claude Code:
|
||||
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
|
||||
client to spawn a fresh subprocess and re-open the pipe. This clears the
|
||||
closed-client state that a bare `kill`/respawn from a terminal does **not**.
|
||||
|
||||
3. **Do not "fix" it by importing the server or poking the process.** Reaching
|
||||
for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
|
||||
killing PIDs to force a respawn, or touching MCP config mtimes does **not**
|
||||
restore the *client's* view of the namespace and violates the daemon-import
|
||||
guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
|
||||
is a **client reconnect / relaunch**.
|
||||
|
||||
4. **Verify through the same path the workflow will use.** After reconnect, call
|
||||
the specific tool the blocked workflow needs — not just any tool — through
|
||||
the target namespace. For a merge that means calling the merger-authorized
|
||||
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
|
||||
namespace does **not** prove another namespace or another tool is callable.
|
||||
Record success with:
|
||||
|
||||
```text
|
||||
gitea_assess_mcp_namespace_health(..., probe_source="client_namespace")
|
||||
```
|
||||
|
||||
5. **If reconnect does not clear it,** relaunch the client entirely, then repeat
|
||||
step 4. If EOF persists after a full relaunch, the backing subprocess is
|
||||
failing to start — inspect its stderr / launch config (command path, venv,
|
||||
`*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still
|
||||
do not use PID kill or config-touch as the primary recovery.
|
||||
|
||||
## Diagnostics to capture when reporting EOF
|
||||
|
||||
Include all of these so the failure is actionable and reproducible:
|
||||
|
||||
- **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
|
||||
`gitea-merger` / `gitea-tools`).
|
||||
- **Tool** that was called and the **exact** error string.
|
||||
- **PID** of the backing process (if any) and whether it was still alive
|
||||
(informational only — not a recovery action).
|
||||
- **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
|
||||
worktree binding such as `GITEA_AUTHOR_WORKTREE`).
|
||||
- **Config path** the client launched the server from.
|
||||
- Result of the **cross-server control** call (did `context7` succeed?).
|
||||
|
||||
## Offline spawn probe (non-authoritative)
|
||||
|
||||
`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
|
||||
subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
|
||||
classifies with `probe_source=offline_spawn`. That is useful for offline
|
||||
launch/registration debugging. It is **not** proof the IDE-managed namespace is
|
||||
healthy. See `docs/mcp-namespace-health.md`.
|
||||
|
||||
## Do-not list during EOF recovery
|
||||
|
||||
- Do **not** retry a blocked merge/adoption until the required tool is confirmed
|
||||
callable through the merger-authorized **client** namespace (see #543).
|
||||
- Do **not** clean, reset, or rebind a **foreign** worktree to work around the
|
||||
error.
|
||||
- Do **not** bypass the namespace with direct imports, raw API/curl, or
|
||||
in-memory state restoration.
|
||||
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||
reconnect.
|
||||
|
||||
## Related
|
||||
|
||||
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
|
||||
@@ -0,0 +1,88 @@
|
||||
# MCP namespace health diagnostics (#543)
|
||||
|
||||
Gitea MCP tools can be registered in the Python FastMCP server while the IDE's
|
||||
live MCP namespace is still unusable. The failure usually appears as
|
||||
`client is closing: EOF`, `transport closed`, or an empty response when calling
|
||||
a tool such as `gitea_whoami`.
|
||||
|
||||
Do not treat static tool registration as proof that review or merge workflows
|
||||
can proceed. A reviewer or merger flow must have **client-namespace** evidence
|
||||
that the required tool is callable through the configured IDE MCP namespace.
|
||||
|
||||
## Probe sources (do not confuse them)
|
||||
|
||||
| Source | How obtained | Proves IDE namespace? |
|
||||
| --- | --- | --- |
|
||||
| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** |
|
||||
| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) |
|
||||
|
||||
`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets
|
||||
`ide_namespace_proven=true` for `client_namespace` success. Offline spawn
|
||||
success never clears review/merge mutation gates.
|
||||
|
||||
## Client-namespace health check (canonical)
|
||||
|
||||
1. Through the IDE client, call a cheap tool on the target namespace
|
||||
(`gitea_whoami` or `gitea_list_profiles`).
|
||||
2. Feed the live result into:
|
||||
|
||||
```text
|
||||
gitea_assess_mcp_namespace_health(
|
||||
namespace="gitea-merger", # or reviewer / author / tools
|
||||
registered_tools=[...], # optional static list
|
||||
probe_result={"success": true, "result": {...}},
|
||||
probe_source="client_namespace",
|
||||
)
|
||||
```
|
||||
|
||||
3. A healthy client-namespace assessment is recorded in the MCP session and
|
||||
consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates.
|
||||
4. If the probe fails with EOF, recover via **client reconnect only** — see
|
||||
`docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP
|
||||
config mtimes as a recovery procedure.
|
||||
|
||||
## Offline spawn probe (non-authoritative)
|
||||
|
||||
```bash
|
||||
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json
|
||||
```
|
||||
|
||||
This script spawns a **separate** server process from config, performs
|
||||
JSON-RPC `initialize` → `tools/list` → `tools/call`, and classifies the
|
||||
result with `probe_source=offline_spawn`. Use it for offline debugging of
|
||||
launch command / registration. It does **not** prove the IDE-managed
|
||||
namespace is healthy.
|
||||
|
||||
By default the script checks:
|
||||
|
||||
| Namespace | Required tool |
|
||||
| --- | --- |
|
||||
| `gitea-author` | `gitea_whoami` |
|
||||
| `gitea-reviewer` | `gitea_whoami` |
|
||||
| `gitea-merger` | `gitea_whoami` |
|
||||
| `gitea-tools` | `gitea_list_profiles` |
|
||||
|
||||
## Recovery (canonical)
|
||||
|
||||
When a namespace returns EOF, follow
|
||||
`docs/mcp-namespace-eof-recovery.md` in order:
|
||||
|
||||
1. Confirm blast radius (Gitea namespace vs all MCP servers).
|
||||
2. **Reconnect the namespace through the client** (IDE reconnect / relaunch).
|
||||
3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime
|
||||
touches — those do not restore the client's closed transport.
|
||||
4. Re-verify the **specific** required tool through the target namespace.
|
||||
5. Resume review/merge only after a successful `client_namespace` assessment.
|
||||
|
||||
## Enforcement
|
||||
|
||||
1. **State machine (read-only):** feed `blocks_merge_workflow` from a
|
||||
`client_namespace` assessment into
|
||||
`gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
|
||||
2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
|
||||
`_live_namespace_health_gate` and fail closed when the session has a
|
||||
recorded unhealthy or non-client probe for the required namespace
|
||||
(`gitea-reviewer` for review, `gitea-merger` for merge).
|
||||
|
||||
When blocked, repair the IDE namespace and re-record a healthy
|
||||
`client_namespace` assessment before retrying the mutation.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Reviewer Handoff Consistency
|
||||
|
||||
Reviewer and final-review controller handoffs must not contradict themselves.
|
||||
A narrative that says a merge happened, a review was blocked, or a terminal
|
||||
mutation budget was consumed must match the mutation ledger fields in the same
|
||||
handoff.
|
||||
|
||||
## What gets validated
|
||||
|
||||
`reviewer_handoff_consistency.assess_reviewer_handoff_consistency()` checks:
|
||||
|
||||
- merge claims appear under `Merge mutations` or `MCP/Gitea mutations`
|
||||
- terminal-mutation-budget claims name the exact prior mutation in the ledger
|
||||
- blocked review submission is not paired with "final decision marked"
|
||||
- reviewer lease acquisition includes a `Review decision`
|
||||
- blocked/rejected mutations include proof fields:
|
||||
- tool called
|
||||
- mutation attempted
|
||||
- mutation rejected
|
||||
- no server-side state changed
|
||||
|
||||
`final_report_validator` applies this as `reviewer.handoff_consistency` on
|
||||
`review_pr` reports and fails closed.
|
||||
|
||||
## Blocked review template
|
||||
|
||||
When `gitea_submit_pr_review` fails closed, use
|
||||
`reviewer_handoff_consistency.render_blocked_review_handoff_template()` or the
|
||||
copy in
|
||||
[`skills/llm-project-workflow/templates/blocked-review-handoff.md`](../skills/llm-project-workflow/templates/blocked-review-handoff.md).
|
||||
|
||||
## Related
|
||||
|
||||
- #331 — file-mutation ledger alignment
|
||||
- #501 — contradictory narrative vs ledger detection
|
||||
+25
-2
@@ -21,5 +21,28 @@ Note on naming: Historical design docs used `jenkins-readonly` / `glitchtip-read
|
||||
|
||||
## 5. Mutation Gating
|
||||
Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins builds) must be explicitly allowed by the execution profile.
|
||||
- **Jenkins build triggers** exist in jenkins-mcp (landed #4) but require dedicated profile/identity and exact confirmation; not on standard reader profiles. See #56 for boundary correction.
|
||||
- **GlitchTip to Gitea issue filing** is documented as a gated, orchestrated workflow (not in glitchtip-mcp), currently partial (mocked, dedup not wired, audit missing). See #57.
|
||||
- **Jenkins build triggers** are gated on a separate write boundary
|
||||
(`jenkins-write-mcp` / `jenkins_mcp.write_server`), not on the read-only
|
||||
`jenkins-mcp` surface. Triggers require a dedicated profile with
|
||||
`jenkins.build.trigger`, exact confirmation, and fail-closed mutation audit.
|
||||
No default profile carries trigger capability (#152 / mcp-control-plane #56).
|
||||
- **GlitchTip to Gitea issue filing** is a library-only orchestrator in
|
||||
mcp-control-plane (not on `glitchtip-mcp`). See #153 / mcp-control-plane #57.
|
||||
|
||||
## 6. Agent Commit Path (no improvised fallbacks)
|
||||
|
||||
When an author execution profile allows **`gitea.repo.commit`** and the
|
||||
**`gitea_commit_files`** tool is visible, agents must use that MCP path for
|
||||
repository commits. Fail closed instead of improvising alternate transports.
|
||||
|
||||
Forbidden when MCP commit is available:
|
||||
|
||||
- WebFetch or other HTTP calls to external base64/decode services
|
||||
- Playwright or browser automation used to work around MCP commit
|
||||
- Manual LLM-generated base64 embedded in throwaway scripts as the primary
|
||||
commit transport
|
||||
|
||||
If shell helpers are unavailable and MCP commit cannot run, stop with a recovery
|
||||
report (restart session, clear hung terminals, use MCP-native commit). See
|
||||
[`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path
|
||||
(#260) and agent temp artifact cleanup (#261).
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Canonical state handoff ledger (#494)
|
||||
|
||||
Gitea is the system of record for continuation. Every discussion, issue, and PR
|
||||
should answer: current state, last proof, who acts next, and the exact prompt for
|
||||
the next role.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Controller opens a discussion.
|
||||
2. Discussion accumulates substantive comments (default minimum: five).
|
||||
3. Controller posts a discussion summary when ready.
|
||||
4. Controller creates linked issues from the summary.
|
||||
5. Author locks issue, implements, opens PR.
|
||||
6. Reviewer reviews at current head.
|
||||
7. Merger merges after formal approval.
|
||||
8. Reconciler closes superseded or already-landed PRs.
|
||||
9. Issue/PR/discussion state comments make the next action obvious at every step.
|
||||
|
||||
## Discussion rules
|
||||
|
||||
- Do **not** convert a discussion into issues until it has at least **five
|
||||
substantive comments**, unless the discussion state comment marks
|
||||
`URGENCY: urgent` or `URGENCY: trivial`.
|
||||
- Substantive comment types: proposal, risk/concern, acceptance criteria,
|
||||
implementation approach, dependency/sequence, summary.
|
||||
- Before issue creation, post a **discussion summary** with decision, issues to
|
||||
create, non-goals, unresolved questions, and next prompt.
|
||||
- Created issues must link back to the discussion; the discussion must link
|
||||
forward to created issues.
|
||||
|
||||
## State comment templates
|
||||
|
||||
Use `state_handoff_ledger.py` helpers or copy the canonical blocks:
|
||||
|
||||
- `render_discussion_state_comment(...)`
|
||||
- `render_discussion_summary_comment(...)`
|
||||
- `render_issue_state_comment(...)`
|
||||
- `render_pr_state_comment(...)`
|
||||
- `render_queue_controller_report(...)`
|
||||
|
||||
Post state comments as the **latest canonical update** on the object. Do not
|
||||
bury state inside PR bodies only.
|
||||
|
||||
## Final report requirements
|
||||
|
||||
Every final report must include a `Controller Handoff` section with:
|
||||
|
||||
- **Current status** — live state after this session
|
||||
- **Next actor** — `author`, `reviewer`, `merger`, `reconciler`, or `controller`
|
||||
- **Next action** — one imperative step
|
||||
- **Next prompt** — ready-to-paste prompt for the next role
|
||||
|
||||
`assess_final_report_next_action_handoff` and
|
||||
`assess_contradictory_state_handoff` enforce these fields and reject
|
||||
contradictory claims (for example, ready-to-merge without approval, issue done
|
||||
without PR proof, discussion complete without summary).
|
||||
|
||||
## Queue controller selection (priority order)
|
||||
|
||||
1. Merge clean approved PRs at current head.
|
||||
2. Review PRs needing review.
|
||||
3. Reconcile superseded or already-landed duplicates.
|
||||
4. Continue blocked-but-now-unblocked issues.
|
||||
5. Create issues from completed discussions.
|
||||
6. Start new author work only when higher-priority queue items are clear.
|
||||
|
||||
For each candidate object, read the **latest canonical state comment** before
|
||||
choosing an action. Output the exact next-role prompt in the controller report.
|
||||
|
||||
## Example workflow states
|
||||
|
||||
| State | Next actor | Typical next action |
|
||||
|-------|------------|---------------------|
|
||||
| Discussion needs more comments | controller | Facilitate discussion until five substantive comments or urgent/trivial exception |
|
||||
| Issue ready for author | author | Lock issue and implement in `branches/` worktree |
|
||||
| PR needs review | reviewer | Review at pinned head in reviewer worktree |
|
||||
| PR approved at head | merger | Merge with merger profile after eligibility proof |
|
||||
| PR superseded | reconciler | Close duplicate/already-landed PR with proof |
|
||||
| Issue blocked | controller | Post issue state comment with blockers and next prompt |
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Chat history is not the source of truth.
|
||||
- Do not weaken author/reviewer/merger/reconciler separation.
|
||||
- Do not replace Gitea issues, PRs, or canonical workflow files under
|
||||
`skills/llm-project-workflow/`.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Two-comment workflow: Controller Handoff + Thread State Ledger
|
||||
|
||||
After meaningful controller/workflow work, post **two separate Gitea comments**:
|
||||
|
||||
1. **`[CONTROLLER HANDOFF]`** — detailed operational handoff for the next
|
||||
LLM/controller session (proof-heavy; may be long).
|
||||
2. **`[THREAD STATE LEDGER]`** — short canonical truth readable in ~30 seconds.
|
||||
|
||||
The ledger is the concise source of truth. The handoff is the detailed
|
||||
continuation artifact. This complements CTH (#505) and lifecycle state
|
||||
comments (#494/#495) without replacing them.
|
||||
|
||||
## Controller Handoff template
|
||||
|
||||
```markdown
|
||||
[CONTROLLER HANDOFF] PR #___ / Issue #___ — <short title>
|
||||
|
||||
Purpose:
|
||||
This comment is the operational handoff for the next controller/LLM session.
|
||||
|
||||
Identity/profile:
|
||||
- Active profile:
|
||||
- Authenticated identity:
|
||||
- Role:
|
||||
- Self-review / role-conflict proof:
|
||||
|
||||
Target:
|
||||
- Repo:
|
||||
- Issue:
|
||||
- PR:
|
||||
- Branch:
|
||||
- Pinned head SHA:
|
||||
- Worktree:
|
||||
|
||||
Work performed:
|
||||
- <step 1>
|
||||
- <step 2>
|
||||
|
||||
Files touched or reviewed:
|
||||
- `<file>` — <why it matters>
|
||||
|
||||
Validation:
|
||||
- `<command>` → <result>
|
||||
- Full suite: <result or not run + reason>
|
||||
|
||||
Server-side mutation ledger:
|
||||
- <mutation 1, including tool/action/comment id if available>
|
||||
- Or: none — no server-side state changed
|
||||
|
||||
Local-only changes:
|
||||
- <worktree created, files edited locally, etc.>
|
||||
- Or: none
|
||||
|
||||
Blockers:
|
||||
- <none>
|
||||
- Or: <exact blocker, exact gate, exact reason>
|
||||
|
||||
Controller prompt for next session:
|
||||
```markdown
|
||||
<ready-to-paste prompt>
|
||||
```
|
||||
```
|
||||
|
||||
## Thread State Ledger template
|
||||
|
||||
```markdown
|
||||
[THREAD STATE LEDGER] PR #___ / Issue #___ — <current state in one line>
|
||||
|
||||
What is true now:
|
||||
- PR state:
|
||||
- Issue state:
|
||||
- Current head SHA:
|
||||
- Server-side decision state:
|
||||
- Local verdict/state:
|
||||
- Latest known validation:
|
||||
|
||||
What changed:
|
||||
- <short summary since prior ledger>
|
||||
|
||||
What is blocked:
|
||||
- Blocker classification: <see list below>
|
||||
|
||||
Who/what acts next:
|
||||
- Next actor:
|
||||
- Required action:
|
||||
- Do not do:
|
||||
- Resume from:
|
||||
```
|
||||
|
||||
### Blocker classifications
|
||||
|
||||
- code blocker
|
||||
- test blocker
|
||||
- merge conflict
|
||||
- stale head
|
||||
- permission/capability blocker
|
||||
- environment/tooling blocker
|
||||
- process/rule blocker
|
||||
- queue/lease blocker
|
||||
- duplicate/canonicalization blocker
|
||||
- no blocker
|
||||
|
||||
### Precise state language
|
||||
|
||||
Prefer:
|
||||
|
||||
- `APPROVE verdict prepared locally`
|
||||
- `APPROVED review posted to Gitea`
|
||||
- `REQUEST_CHANGES posted to Gitea`
|
||||
- `merge performed` / `merge not performed`
|
||||
- `lease acquired` / `lease attempt blocked`
|
||||
- `server-side state changed` / `no server-side state changed`
|
||||
|
||||
Avoid ambiguous standalone claims (`approved`, `ready`, `merged`, `blocked`,
|
||||
`done`) without proof and server-side state separation.
|
||||
|
||||
## Validation
|
||||
|
||||
- `thread_state_ledger_validator.py` — comment and final-report checks
|
||||
- `gitea_create_issue_comment` — fail-closed gate on tagged comments
|
||||
- `assess_final_report_validator` — `shared.two_comment_workflow` rule
|
||||
|
||||
Examples: [`examples/two-comment-workflow-examples.md`](examples/two-comment-workflow-examples.md)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Web UI deployment boundary (#435)
|
||||
|
||||
The MCP Control Plane web UI is an **internal operator console**, not a
|
||||
customer-facing application. The MVP assumes local or trusted-network access
|
||||
only.
|
||||
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
|
||||
Do **not** expose the UI on the public internet without an access layer.
|
||||
|
||||
## Beyond localhost
|
||||
|
||||
If the UI must be reachable outside the operator laptop:
|
||||
|
||||
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
|
||||
authenticated staff reach the service.
|
||||
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
|
||||
without understanding the exposure.
|
||||
3. Set explicit override env vars only after access controls are in place:
|
||||
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
|
||||
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
|
||||
|
||||
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
|
||||
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
|
||||
| `WEBUI_PORT` | `8765` | Listen port |
|
||||
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
|
||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
|
||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
|
||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
|
||||
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
|
||||
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
|
||||
|
||||
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
|
||||
the server when a page needs live Gitea data (e.g. `/queue`). They are not
|
||||
shipped to the browser.
|
||||
|
||||
## Health / deployment metadata
|
||||
|
||||
`GET /health` includes a `deployment` object with bind disposition, runtime
|
||||
assumption paths, and the client-secret policy. Use it to verify an instance is
|
||||
configured for internal-only operation.
|
||||
|
||||
## Non-goals (MVP)
|
||||
|
||||
- Full SSO or session login in the UI
|
||||
- Hosting on the public internet without Access/VPN/WARP
|
||||
- Embedding Gitea tokens in the frontend bundle
|
||||
@@ -0,0 +1,208 @@
|
||||
# Internal web UI — local development (#426)
|
||||
|
||||
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
|
||||
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
|
||||
truth; this UI only provides route stubs and layout.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
|
||||
- No secrets in repo, config, or client bundle
|
||||
|
||||
## Start the server
|
||||
|
||||
From the repository root (or an issue worktree):
|
||||
|
||||
```bash
|
||||
./scripts/run-webui
|
||||
```
|
||||
|
||||
Or directly:
|
||||
|
||||
```bash
|
||||
python3 -m webui
|
||||
```
|
||||
|
||||
Optional environment variables:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||
| `WEBUI_PORT` | `8765` | Listen port |
|
||||
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
|
||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
|
||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
|
||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
|
||||
|
||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Home / operator overview |
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||
| `/api/queue` | JSON queue export with pagination metadata |
|
||||
| `/projects` | Project registry list (#427) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
| `/api/runtime` | JSON runtime health export |
|
||||
| `/audit` | Report audit paste + validator preview (#431) |
|
||||
| `/api/audit` | JSON validator preview (POST `report_text`, optional `task_kind`) |
|
||||
| `/worktrees` | Worktree hygiene dashboard (#432) |
|
||||
| `/api/worktrees` | JSON worktree scan with classifications and anomalies |
|
||||
| `/actions` | Gated write-action registry — all disabled in MVP (#434) |
|
||||
| `/api/actions` | JSON action registry with capability metadata |
|
||||
| `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) |
|
||||
| `/leases` | Lease and collision visibility (#433) |
|
||||
| `/api/leases` | JSON lease/collision export |
|
||||
|
||||
Most routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
|
||||
`read-only-mvp`, except `/audit` and `/api/audit` which accept POST for
|
||||
local validator preview only (no Gitea mutations, no server-side storage).
|
||||
|
||||
## Report audit (#431)
|
||||
|
||||
Paste an LLM final report at `/audit` or POST JSON to `/api/audit`. The UI
|
||||
reuses `final_report_validator` and review schema checks to surface missing
|
||||
proof fields, wrong validation vocabulary, mutation contradictions, and a
|
||||
suggested next prompt or issue-comment draft. Task kind can be auto-detected
|
||||
or selected explicitly.
|
||||
|
||||
## Project registry (#427)
|
||||
|
||||
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
|
||||
|
||||
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
|
||||
copy outside git. The registry stores repo identity, remotes, profile names,
|
||||
workflow/schema path references, and onboarding checklist steps — never tokens
|
||||
or credentials.
|
||||
|
||||
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
||||
`prgs-reviewer`, and `prgs-reconciler` profiles.
|
||||
|
||||
## Prompt library (#428)
|
||||
|
||||
Prompts are generated at load time from canonical workflow files under
|
||||
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
|
||||
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
|
||||
copy/paste starters; canonical workflow files remain the only full policy
|
||||
source.
|
||||
|
||||
## Live queue dashboard (#429)
|
||||
|
||||
`/queue` loads open PRs and issues for the default registry project (seed:
|
||||
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
|
||||
credentials. The UI surfaces pagination proof (returned count, pages fetched,
|
||||
`has_more`, `inventory_complete`) and classification badges (`claimed`,
|
||||
`blocked`, `in-review`, `duplicate`) when evidence exists.
|
||||
|
||||
If credentials are missing or the fetch fails, the page shows an explicit error
|
||||
instead of an empty queue (fail closed).
|
||||
|
||||
## Gated actions (#434)
|
||||
|
||||
`/actions` registers future write actions (claim, comment, review, merge,
|
||||
delete branch, create PR/issue). Each entry declares the MCP tool, required
|
||||
permission, and profile role from `task_capability_map.py` — aligned with
|
||||
`gitea_resolve_task_capability`. Buttons are disabled; previews always render
|
||||
a mutation ledger. Direct `attempt_action` calls fail closed without invoking
|
||||
MCP tools.
|
||||
|
||||
## Worktree hygiene (#432)
|
||||
|
||||
`/worktrees` scans local `branches/` directories and registered git worktrees.
|
||||
Each entry is classified (`active-pr`, `active-issue`, `dirty`, `stale-clean`,
|
||||
`detached-review`, `unsafe-unknown`, `orphan`). Missing preserved worktrees
|
||||
referenced by the issue lock file are flagged as anomalies (#404). The page
|
||||
includes a copy/paste canonical cleanup prompt only — no deletion actions.
|
||||
|
||||
Override scan root with `WEBUI_REPO_ROOT` (defaults to repository root).
|
||||
|
||||
## Lease visibility (#433)
|
||||
|
||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||
and duplicate local branches per issue. Links to collision-history backend
|
||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||
|
||||
## Runtime health (#430)
|
||||
|
||||
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||
project: active profile and role kind, authenticated identity (when credentials
|
||||
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Deployment boundary (#435)
|
||||
|
||||
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
||||
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
|
||||
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
|
||||
|
||||
## Tests (#436)
|
||||
|
||||
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
||||
|
||||
```bash
|
||||
./scripts/test-webui
|
||||
```
|
||||
|
||||
CI / Jenkins multibranch can call the path-filtered gate (runs only when the
|
||||
diff touches `webui/`, `tests/test_webui_*`, or web UI docs/scripts):
|
||||
|
||||
```bash
|
||||
./scripts/ci-webui-check
|
||||
WEBUI_CI_FORCE=1 ./scripts/ci-webui-check # always run
|
||||
```
|
||||
|
||||
`scripts/test-webui` sets `WEBUI_TEST_OFFLINE=1` by default. In that mode the
|
||||
queue, lease, and runtime routes use empty offline snapshots instead of Gitea
|
||||
credentials, so CI can run without MCP daemon credential access. Set
|
||||
`WEBUI_TEST_OFFLINE=0` only when deliberately validating live fetch behavior.
|
||||
|
||||
Or invoke unittest directly:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -p 'test_webui_*.py' -q
|
||||
```
|
||||
|
||||
## Lease visibility (#433)
|
||||
|
||||
`/leases` surfaces read-only lease and collision state: local issue lock file,
|
||||
in-progress claim inventory (#268), reviewer PR lease comments when present
|
||||
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
|
||||
and duplicate local branches per issue. Links to collision-history backend
|
||||
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
|
||||
|
||||
## Runtime health (#430)
|
||||
|
||||
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
|
||||
project: active profile and role kind, authenticated identity (when credentials
|
||||
are available), config model/mode, local vs remote `master` SHA sync, shell
|
||||
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||
no tokens or MCP restart actions are exposed.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# Project History
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
## 2026-07-06
|
||||
|
||||
- **Wiki bootstrap (#224)** — Added repo-tracked `docs/wiki/` (10 pages), `scripts/sync-gitea-wiki.sh`, PR template gate, and sync safety tests for Gitea-Tools publication.
|
||||
|
||||
## Prior milestones
|
||||
|
||||
- Gated review/merge path (#16), task capability resolver (#69), issue-write tool gates.
|
||||
- Canonical JSON execution profiles (#19) and thin MCP launchers.
|
||||
- Role session router (#206) and review decision lock (#211).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Gitea-Tools Project Wiki
|
||||
|
||||
Welcome to the version-controlled wiki for the Gitea-Tools MCP server and CLI tooling.
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md)
|
||||
- [Operator Guide](Operator-Guide.md)
|
||||
- [Repositories Map](Repositories.md)
|
||||
- [Identity and Profiles](Identity-and-Profiles.md)
|
||||
- [Workflow Model](Workflow.md)
|
||||
- [Safety and Gates](Safety-and-Gates.md)
|
||||
- [MCP Tools Reference](MCP-Tools.md)
|
||||
- [Operator Runbooks](Runbooks.md)
|
||||
- [Open Decisions](Open-Decisions.md)
|
||||
- [Project History](History.md)
|
||||
|
||||
## Gitea Wiki mirror
|
||||
|
||||
These repo-tracked pages are the **source of truth**. The Gitea native Wiki
|
||||
for this repository is a read-only convenience mirror generated from this
|
||||
directory with `scripts/sync-gitea-wiki.sh` (dry-run by default; see
|
||||
[Runbooks](Runbooks.md)). Never edit the Gitea Wiki directly — change the
|
||||
pages here through a PR, then sync.
|
||||
|
||||
## Project overview
|
||||
|
||||
Gitea-Tools provides the Gitea MCP server, execution-profile configuration,
|
||||
identity handling, and CLI helpers used across MCP Control Plane workflows.
|
||||
It enforces author/reviewer separation, gated review/merge, task-capability
|
||||
resolution, and fail-closed safety rails in code.
|
||||
|
||||
Canonical long-form docs also live under `docs/` in the repository (for example
|
||||
`docs/gitea-execution-profiles.md` and `docs/llm-workflow-runbooks.md`). The
|
||||
wiki summarizes operator-facing rules; the repo docs carry implementation detail.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Identity and Profiles
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
The LLM is not the role — the **MCP execution profile** is the role. Profiles
|
||||
bind an authenticated Gitea identity to an allowed operation set.
|
||||
|
||||
## Reference profiles (prgs)
|
||||
|
||||
### Author / implementer
|
||||
|
||||
- **Profile:** `prgs-author`
|
||||
- **Typical identity:** `jcwalker3`
|
||||
- **Allowed:** branch create/push, PR create, issue comment/create/close, repo commit, read.
|
||||
- **Forbidden:** PR approve, merge, request_changes.
|
||||
|
||||
### Reviewer
|
||||
|
||||
- **Profile:** `prgs-reviewer`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR review/approve/request_changes, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR merge.
|
||||
|
||||
Review and merge are separate workflow roles. A reviewer approval is not merge authorization.
|
||||
|
||||
### Merger
|
||||
|
||||
- **Profile:** `prgs-merger`
|
||||
- **Typical identity:** `sysadmin`
|
||||
- **Allowed:** PR merge, issue comment, read.
|
||||
- **Forbidden:** branch push, PR create, repo commit, PR approve, PR review, PR request_changes.
|
||||
|
||||
## Configuration
|
||||
|
||||
Profiles are defined in the canonical JSON config (`GITEA_MCP_CONFIG`, typically
|
||||
`~/.config/gitea-tools/profiles.json`). Launchers are thin: they set
|
||||
`GITEA_MCP_PROFILE` and point at the config file. Credentials resolve from
|
||||
keychain or env references — never inline in client configs.
|
||||
|
||||
See `docs/gitea-execution-profiles.md` in the repository for the full model.
|
||||
|
||||
## Profile switching
|
||||
|
||||
Use separate MCP server namespaces (`gitea-tools` author vs `gitea-reviewer`)
|
||||
or distinct launcher entries. Runtime in-place profile switching is disabled by
|
||||
default (fail closed).
|
||||
@@ -0,0 +1,36 @@
|
||||
# MCP Tools Reference
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
## Identity and capability
|
||||
|
||||
- `gitea_whoami` — authenticated user and active profile metadata.
|
||||
- `gitea_get_profile` / `gitea_get_runtime_context` — allowed and forbidden operations.
|
||||
- `gitea_resolve_task_capability` — required pre-flight for gated mutations.
|
||||
- `gitea_route_task_session` — role/session router before task execution.
|
||||
|
||||
## Author tools
|
||||
|
||||
- `gitea_create_issue`, `gitea_create_issue_comment`, `gitea_close_issue`
|
||||
- `gitea_mark_issue`, `gitea_set_issue_labels`, `gitea_lock_issue`
|
||||
- `gitea_create_pr`, `gitea_edit_pr`, `gitea_commit_files`
|
||||
- `gitea_delete_branch`
|
||||
|
||||
## Reviewer tools
|
||||
|
||||
- `gitea_check_pr_eligibility` — read-only eligibility check.
|
||||
- `gitea_dry_run_pr_review` — validation-phase review mechanics.
|
||||
- `gitea_mark_final_review_decision` — mark validation complete.
|
||||
- `gitea_submit_pr_review` / `gitea_review_pr` — gated live review.
|
||||
- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407).
|
||||
- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536).
|
||||
- `gitea_merge_pr` — gated merge (only merge path).
|
||||
|
||||
## Read tools
|
||||
|
||||
- `gitea_list_prs`, `gitea_view_pr`, `gitea_list_issues`, `gitea_view_issue`
|
||||
- `gitea_get_file`, `gitea_list_labels`, `gitea_mirror_refs`
|
||||
|
||||
See the repository `README.md` for the full tool table and client setup.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Open Decisions
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
Pending architectural and workflow decisions:
|
||||
|
||||
- **Operation-scoped role selection (#228)** — Move from launcher-profile-dependent routing to per-operation profile resolution.
|
||||
- **Gitea-Tools wiki for dadeschools remote** — This bootstrap covers `prgs`; dadeschools instance wiki parity is undecided.
|
||||
- **Server-side self-merge block** — Complement tool gates with Gitea branch protection where available.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Operator Guide
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
Handbook for LLM operators and human developers using the Gitea-Tools MCP server.
|
||||
|
||||
## Key rules
|
||||
|
||||
1. **Verify identity first** — Run `gitea_whoami` and confirm the active profile before any mutation.
|
||||
2. **Resolve task capability** — Call `gitea_resolve_task_capability` for the intended task before gated tools.
|
||||
3. **One unit of work per session** — Implement one claimed issue *or* review/merge one PR; do not mix author and reviewer mutations in one session.
|
||||
4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored.
|
||||
5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions.
|
||||
6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions.
|
||||
|
||||
## Supported Gitea instances
|
||||
|
||||
| Remote | Host | Default org/repo |
|
||||
|--------|------|------------------|
|
||||
| `dadeschools` | `gitea.dadeschools.net` | `Contractor / Timesheet` |
|
||||
| `prgs` | `gitea.prgs.cc` | `Scaled-Tech-Consulting / Timesheet` |
|
||||
|
||||
Always pass `remote` explicitly on tool calls. The server default is `dadeschools`; forgetting `remote` on a `prgs` task hits the wrong host.
|
||||
|
||||
## New session checklist
|
||||
|
||||
1. `gitea_whoami` — confirm authenticated user and profile.
|
||||
2. `gitea_get_runtime_context` — allowed/forbidden operations for this session.
|
||||
3. `gitea_resolve_task_capability` — prove the session may perform the planned task.
|
||||
4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Repositories Map
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
Active MCP Control Plane repositories (authoritative inventory for wiki publication gate #224 / #87):
|
||||
|
||||
| Repository | Purpose | Wiki required |
|
||||
|------------|---------|---------------|
|
||||
| `Scaled-Tech-Consulting/Gitea-Tools` | Gitea MCP server, execution profiles, workflow tooling | Yes — live Gitea Wiki on Wiki tab |
|
||||
| `Scaled-Tech-Consulting/mcp-control-plane` | Orchestrators, audit trails, multi-service controller workflows | Yes — live Gitea Wiki on Wiki tab |
|
||||
|
||||
Repo-tracked `docs/wiki/` is the source of truth; the Gitea Wiki is a mirror.
|
||||
Wiki-related issues cannot be closed until the live Wiki is verified — see
|
||||
[Safety and Gates](Safety-and-Gates.md) and [Runbooks](Runbooks.md).
|
||||
|
||||
### Gitea-Tools
|
||||
|
||||
- **Repository:** `Scaled-Tech-Consulting/Gitea-Tools`
|
||||
- **Purpose:** Gitea MCP server, profile configuration, CLI scripts, safety gates.
|
||||
- **Base branch:** `master`
|
||||
|
||||
### mcp-control-plane
|
||||
|
||||
- **Repository:** `Scaled-Tech-Consulting/mcp-control-plane`
|
||||
- **Purpose:** Controller workflows, Jenkins/GlitchTip integrations, audit orchestration.
|
||||
- **Base branch:** `master`
|
||||
|
||||
## Wiki publication status (#224 readiness gate)
|
||||
|
||||
| Repository | `docs/wiki/` source | Gitea Wiki published | Proof |
|
||||
|---|---|---|---|
|
||||
| `Scaled-Tech-Consulting/Gitea-Tools` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/wiki/Home); 10 pages; wiki git log head `d1f0693` |
|
||||
| `Scaled-Tech-Consulting/mcp-control-plane` | yes (10 pages) | published (verified 2026-07-06) | [Wiki Home](https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane/wiki/Home); 10 pages (History, Home, Identity-and-Profiles, MCP-Tools, Open-Decisions, Operator-Guide, Repositories, Runbooks, Safety-and-Gates, Workflow); wiki git log head `ef3dec2` |
|
||||
|
||||
|
||||
Update this table whenever a wiki is published, re-synced, or found stale.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Operator Runbooks
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
## PR review and merge
|
||||
|
||||
1. `gitea_resolve_task_capability(task="review_pr")`
|
||||
2. `gitea_check_pr_eligibility` for review and merge actions.
|
||||
3. Validate locally: tests, `py_compile`, `git diff --check`.
|
||||
4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`.
|
||||
5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR <n>"`.
|
||||
|
||||
## Gitea Wiki sync
|
||||
|
||||
The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes:
|
||||
|
||||
1. Preview (no network):
|
||||
```bash
|
||||
scripts/sync-gitea-wiki.sh
|
||||
```
|
||||
2. Operator-confirmed push:
|
||||
```bash
|
||||
GITEA_WIKI_SYNC_CONFIRM="SYNC WIKI Gitea-Tools" scripts/sync-gitea-wiki.sh --push
|
||||
```
|
||||
3. If clone fails on first run, bootstrap Home via Gitea API or UI, then re-run.
|
||||
|
||||
The script mirrors only `docs/wiki/*.md`, never deletes wiki pages, and never
|
||||
prints credentials.
|
||||
|
||||
## Wiki Publication Readiness Gate (#224)
|
||||
|
||||
Actual Gitea Wiki publication is a **required repo readiness gate**.
|
||||
|
||||
1. **Closure prevention** — No wiki issue may close on markdown/helper work alone.
|
||||
Closing requires live-Wiki proof: Wiki Home link plus page listing or wiki git log.
|
||||
2. **Reviewer checklist** — PR template requires verifying the repo **Wiki tab**.
|
||||
3. **Publication authority** — `--push` requires exact `GITEA_WIKI_SYNC_CONFIRM` phrase.
|
||||
4. **Per-repo status** — Update [Repositories Map](Repositories.md) when published.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Safety and Gates
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
*Prompts express intent; MCP tools enforce safety.*
|
||||
|
||||
## Primary gates
|
||||
|
||||
1. **Fail closed** — Unknown tasks, missing capability resolution, or profile mismatches block mutations.
|
||||
2. **Task capability map** — `gitea_resolve_task_capability` must precede gated issue/PR mutations.
|
||||
3. **Issue lock** — `gitea_lock_issue` required before author implementation on a tracked issue.
|
||||
4. **Head SHA pinning** — Reviews and merges refuse when the PR head moved.
|
||||
5. **Explicit merge confirmation** — `gitea_merge_pr` requires `confirmation="MERGE PR <n>"`.
|
||||
6. **No self-review / self-merge** — Authenticated user must differ from PR author for approve/merge.
|
||||
7. **Review decision lock** — Live review mutations require validation-phase dry-run and `gitea_mark_final_review_decision`.
|
||||
8. **Terminal review hard-stop (#332)** — After a terminal live review mutation, only same-PR merge after `approve` may continue. Durable locks (#559) must not be deleted by hand.
|
||||
9. **Stale decision-lock cleanup (#594)** — `gitea_cleanup_stale_review_decision_lock` may clear a durable #332 lock **only** when the last terminal mutation's PR is live-state **merged or closed**, identity/profile gates pass, and apply uses a reviewer-capable profile. Open/ambiguous locks stay fail-closed. Successful cleanup records a durable audit trail.
|
||||
10. **Redaction** — Tokens, passwords, and keychain material never appear in tool output.
|
||||
11. **Wiki publication (#224)** — `docs/wiki/` and the sync helper are prerequisites only. Closing a wiki issue requires live Gitea Wiki proof on the repo Wiki tab. See [Runbooks](Runbooks.md#wiki-publication-readiness-gate-224).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Workflow Model
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Home](Home.md) | [Operator Guide](Operator-Guide.md) | [Repositories Map](Repositories.md) | [Identity and Profiles](Identity-and-Profiles.md) | [Workflow Model](Workflow.md) | [Safety and Gates](Safety-and-Gates.md) | [MCP Tools Reference](MCP-Tools.md) | [Operator Runbooks](Runbooks.md) | [Open Decisions](Open-Decisions.md) | [Project History](History.md)
|
||||
|
||||
## Step 1: Review queue first (reviewer profile)
|
||||
|
||||
1. `gitea_resolve_task_capability(task="review_pr")`
|
||||
2. List open PRs; pick the oldest eligible PR (not self-authored, mergeable).
|
||||
3. Pin head SHA; validate diff scope and run tests locally.
|
||||
4. `gitea_mark_final_review_decision` → `gitea_review_pr` / `gitea_submit_pr_review`
|
||||
5. `gitea_merge_pr` only with `confirmation="MERGE PR <n>"` and pinned head SHA.
|
||||
|
||||
## Step 2: Implement issues (author profile)
|
||||
|
||||
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
|
||||
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
|
||||
handoffs, merged-PR completion). Stop if another session owns the lease.
|
||||
`gitea_lock_issue` records an operation-scoped `author_issue_work` lease
|
||||
with issue, branch, worktree, claimant, created, expiry, and heartbeat
|
||||
fields; active or expired same-operation leases require recovery review
|
||||
before takeover.
|
||||
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
|
||||
mutate only from a `branches/` worktree after proving root, cwd, branch,
|
||||
stable main-checkout branch, and session worktree path (no exceptions).
|
||||
1. `gitea_resolve_task_capability` for the author task.
|
||||
2. `gitea_lock_issue` before implementation mutations.
|
||||
3. Claim with `gitea_mark_issue` / `status:in-progress` label.
|
||||
4. Branch, implement, test, push, `gitea_create_pr`.
|
||||
5. Release claim when done; never self-review the PR.
|
||||
|
||||
## Step 3: Operator follow-ups
|
||||
|
||||
Wiki publication, credential provisioning, and cross-repo mirror operations are
|
||||
operator-confirmed actions — see [Runbooks](Runbooks.md).
|
||||
|
||||
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Workflow skill mount across runtimes (#551)
|
||||
|
||||
## Problem
|
||||
|
||||
Controller prompts require **`gitea-workflow`**, but:
|
||||
|
||||
- Claude may load `~/.claude/skills/gitea-workflow`
|
||||
- Codex often has **no** `~/.codex/skills/gitea-workflow`
|
||||
- The portable package in-repo is `skills/llm-project-workflow`
|
||||
- `mcp_list_project_skills` historically listed operational guides only, not
|
||||
the workflow router
|
||||
|
||||
Sessions then either **block** incorrectly or **proceed without** the workflow
|
||||
wall.
|
||||
|
||||
## Canonical names (must resolve to the same skill)
|
||||
|
||||
| Name | Use |
|
||||
|------|-----|
|
||||
| `gitea-workflow` | **Primary** controller / Codex skill name |
|
||||
| `llm-project-workflow` | Portable in-repo package name |
|
||||
| `git-pr-workflows` | Legacy alias |
|
||||
|
||||
Source of truth: `skills/llm-project-workflow/SKILL.md`
|
||||
In-repo alias stub: `skills/gitea-workflow/SKILL.md`
|
||||
|
||||
## Codex install
|
||||
|
||||
From a `branches/` worktree (or any clone of the repo):
|
||||
|
||||
```bash
|
||||
./scripts/install-codex-workflow-skill.sh
|
||||
# optional:
|
||||
./scripts/install-codex-workflow-skill.sh --dry-run
|
||||
./scripts/install-codex-workflow-skill.sh --skills-dir "$HOME/.codex/skills"
|
||||
```
|
||||
|
||||
This symlinks the portable package under all three names. **Restart Codex**
|
||||
after install.
|
||||
|
||||
## MCP discovery
|
||||
|
||||
- `mcp_list_project_skills` includes `gitea-workflow`, `llm-project-workflow`,
|
||||
and `git-pr-workflows`.
|
||||
- `mcp_get_skill_guide("<name>")` returns the same router steps for each.
|
||||
- `mcp_check_workflow_skill_preflight` proves the in-repo skill file exists and
|
||||
reports Codex mount status.
|
||||
|
||||
## Preflight rule
|
||||
|
||||
Before any git or Gitea mutation:
|
||||
|
||||
1. Call `mcp_check_workflow_skill_preflight`.
|
||||
2. If `blocked` / `workflow_skill_ready` is false → **BLOCKED + DIAGNOSE**; do
|
||||
not mutate.
|
||||
3. Load the skill by **any** canonical name and follow the router.
|
||||
|
||||
Missing Codex mount alone does not block if the in-repo skill is present and
|
||||
loaded via MCP/docs; operators should still install the Codex symlink so
|
||||
prompt names resolve natively.
|
||||
|
||||
## Controller prompts
|
||||
|
||||
Prefer:
|
||||
|
||||
> Invoke skill `gitea-workflow` (alias of `llm-project-workflow`).
|
||||
|
||||
Do not require a name that is only available on one runtime.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,8 +52,19 @@
|
||||
"execution_profile": "example-reviewer",
|
||||
"audit_label": "example-reviewer",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes", "merge"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr"]
|
||||
"allowed_operations": ["read", "review", "comment", "issue.comment", "approve", "request_changes"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr", "merge"]
|
||||
},
|
||||
"example-merger": {
|
||||
"enabled": true,
|
||||
"context": "example-context",
|
||||
"role": "merger",
|
||||
"username": "reviewer-user",
|
||||
"execution_profile": "example-merger",
|
||||
"audit_label": "example-merger",
|
||||
"auth": { "type": "keychain", "id": "example-gitea-reviewer-token" },
|
||||
"allowed_operations": ["read", "comment", "issue.comment", "merge"],
|
||||
"forbidden_operations": ["branch", "commit", "push", "open_pr", "approve", "review", "request_changes"]
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
@@ -63,7 +74,8 @@
|
||||
"default_owner": "Example-Org",
|
||||
"default_repo": "Example-Repo",
|
||||
"default_author_profile": "example-author",
|
||||
"default_reviewer_profile": "example-reviewer"
|
||||
"default_reviewer_profile": "example-reviewer",
|
||||
"default_merger_profile": "example-merger"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
|
||||
+10
-2
@@ -163,12 +163,13 @@ def audit_enabled():
|
||||
def build_event(*, action, result, remote=None, server=None, repository=None,
|
||||
issue_number=None, pr_number=None, profile_name=None,
|
||||
audit_label=None, authenticated_username=None, target_branch=None,
|
||||
head_sha=None, reason=None, request_metadata=None, now=None):
|
||||
head_sha=None, reason=None, request_metadata=None, now=None,
|
||||
mcp_namespace=None, task_role=None, operation=None):
|
||||
"""Build a redacted, JSON-able audit record for a mutating action."""
|
||||
ts = now or datetime.datetime.now(datetime.timezone.utc)
|
||||
if isinstance(ts, datetime.datetime):
|
||||
ts = ts.isoformat()
|
||||
return {
|
||||
event = {
|
||||
"timestamp": ts,
|
||||
"action": action,
|
||||
"action_type": "mutating",
|
||||
@@ -186,6 +187,13 @@ def build_event(*, action, result, remote=None, server=None, repository=None,
|
||||
"reason": _redact_str(reason) if reason else reason,
|
||||
"request_metadata": redact(request_metadata) if request_metadata is not None else None,
|
||||
}
|
||||
if mcp_namespace is not None:
|
||||
event["mcp_namespace"] = mcp_namespace
|
||||
if task_role is not None:
|
||||
event["task_role"] = task_role
|
||||
if operation is not None:
|
||||
event["operation"] = operation
|
||||
return event
|
||||
|
||||
|
||||
def write_event(event, path=None):
|
||||
|
||||
+51
-2
@@ -20,14 +20,15 @@ from dotenv import dotenv_values, load_dotenv
|
||||
|
||||
import gitea_config
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# Load standard .env if present
|
||||
load_dotenv()
|
||||
load_dotenv(os.path.join(PROJECT_ROOT, ".env"))
|
||||
|
||||
# Dictionary to store configurations parsed dynamically from .env.* files
|
||||
DYNAMIC_CONFIGS = {}
|
||||
|
||||
# Scan all files starting with .env in the project root to load multiple configurations
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")):
|
||||
# Skip directories and the example template
|
||||
if os.path.basename(env_path) == ".env.example":
|
||||
@@ -104,6 +105,10 @@ def get_credentials(host):
|
||||
|
||||
# 3. Optional fallback to macOS Keychain via git credential fill
|
||||
if not user and not password and os.environ.get("GITEA_USE_KEYCHAIN") == "1":
|
||||
# #558: block raw keychain dumps outside the sanctioned MCP daemon.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||
cmd_parts = ["git", "creden" + "tial", "fi" + "ll"]
|
||||
try:
|
||||
p = subprocess.Popen(
|
||||
@@ -116,6 +121,8 @@ def get_credentials(host):
|
||||
user = line.split("=", 1)[1]
|
||||
elif line.startswith("password="):
|
||||
password = line.split("=", 1)[1]
|
||||
except mcp_daemon_guard.UnsanctionedRuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -124,6 +131,11 @@ def get_credentials(host):
|
||||
|
||||
def get_auth_header(host):
|
||||
"""Return an ``Authorization`` header value for *host*."""
|
||||
# #558: resolving credentials for API mutation must not happen via ad-hoc
|
||||
# direct imports that bypass the MCP daemon preflight wall.
|
||||
import mcp_daemon_guard
|
||||
|
||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("get_auth_header")
|
||||
host_key = host.lower().strip()
|
||||
|
||||
# 1. Try Token-based auth from dynamic configs
|
||||
@@ -420,6 +432,43 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
|
||||
return results
|
||||
|
||||
|
||||
def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs):
|
||||
"""Fetch one page from a Gitea list endpoint with explicit pagination metadata.
|
||||
|
||||
Returns ``(items, pagination)`` where *pagination* includes ``has_more``,
|
||||
``next_page``, and ``is_final_page`` derived from the returned page length.
|
||||
"""
|
||||
page = max(1, int(page))
|
||||
limit = max(1, min(50, int(limit)))
|
||||
page_url = _add_query(url, page=page, limit=limit)
|
||||
data = api_request("GET", page_url, auth_header, **kwargs)
|
||||
if data is None:
|
||||
pagination = {
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"returned_count": 0,
|
||||
"has_more": False,
|
||||
"next_page": None,
|
||||
"is_final_page": True,
|
||||
}
|
||||
return [], pagination
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError(
|
||||
f"expected a list page from Gitea, got {type(data).__name__}"
|
||||
)
|
||||
returned = len(data)
|
||||
has_more = returned >= limit
|
||||
pagination = {
|
||||
"page": page,
|
||||
"per_page": limit,
|
||||
"returned_count": returned,
|
||||
"has_more": has_more,
|
||||
"next_page": page + 1 if has_more else None,
|
||||
"is_final_page": not has_more,
|
||||
}
|
||||
return data, pagination
|
||||
|
||||
|
||||
def gitea_url(host, path):
|
||||
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
|
||||
if not path.startswith("/"):
|
||||
|
||||
+9
-1
@@ -205,7 +205,7 @@ def selected_profile_name():
|
||||
|
||||
|
||||
def is_runtime_switching_enabled(path=None):
|
||||
"""Check if runtime profile switching is explicitly enabled in config."""
|
||||
"""Check if runtime profile switching is enabled in config."""
|
||||
try:
|
||||
config = load_config(path)
|
||||
except Exception:
|
||||
@@ -213,10 +213,18 @@ def is_runtime_switching_enabled(path=None):
|
||||
if not config:
|
||||
return False
|
||||
rules = config.get("rules") or {}
|
||||
if rules.get("allow_runtime_switching") is False:
|
||||
return False
|
||||
if config.get("allow_runtime_switching") is False:
|
||||
return False
|
||||
if rules.get("allow_runtime_switching") is True:
|
||||
return True
|
||||
if config.get("allow_runtime_switching") is True:
|
||||
return True
|
||||
# Default to True if multiple profiles exist in the config
|
||||
profiles = config.get("profiles") or {}
|
||||
if len(profiles) > 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+13096
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,788 @@
|
||||
"""Observability incident bridge (#612).
|
||||
|
||||
Converts Sentry/GlitchTip **observations** into durable **Gitea issues** and
|
||||
``incident_links`` rows on the #613 control-plane DB.
|
||||
|
||||
Hard rules (ADR):
|
||||
* Gitea owns work; providers own incidents; the bridge only links them.
|
||||
* Raw monitoring incidents are **never** assignable ``work_items``.
|
||||
* The #600 allocator sees bridge work only as normal Gitea issues.
|
||||
* Phase-1 prefers dry-run / explicit reconcile; apply creates/links issues.
|
||||
* No tokens, DSNs, cookies, or other secrets in bodies, DB, or logs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
from control_plane_db import ControlPlaneDB, ControlPlaneError, WORK_KINDS, _norm_scope
|
||||
|
||||
PROVIDERS = frozenset({"sentry", "glitchtip"})
|
||||
|
||||
OUTCOME_PREVIEW = "preview"
|
||||
OUTCOME_LINKED = "linked_existing"
|
||||
OUTCOME_CREATED = "created_issue"
|
||||
OUTCOME_UPDATED = "updated_link"
|
||||
OUTCOME_BLOCKED = "blocked"
|
||||
OUTCOME_NO_ACTION = "no_safe_action"
|
||||
|
||||
# Patterns scrubbed from any bridge-facing text (bodies, titles, tags, logs).
|
||||
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(
|
||||
r"(?i)\b(api[_-]?token|token|secret|password|passwd)\s*[:=]\s*\S+"
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)\bAuthorization\s*[:=]\s*(?:Bearer\s+)?[A-Za-z0-9._\-+=/]{8,}"
|
||||
),
|
||||
re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._\-]{8,}"),
|
||||
re.compile(r"(?i)\bBasic\s+[A-Za-z0-9+/=]{8,}"),
|
||||
re.compile(r"(?i)\b(dsn|sentry_dsn|glitchtip_dsn)\s*[:=]\s*\S+"),
|
||||
re.compile(r"https?://[^/\s]+:[^@/\s]+@"), # user:pass@host
|
||||
re.compile(r"(?i)\b(keychain[_-]?id|private[_-]?key)\s*[:=]\s*\S+"),
|
||||
re.compile(r"(?i)cookie\s*[:=]\s*[^\s;]+"),
|
||||
re.compile(r"(?i)\bsession[_-]?id\s*[:=]\s*\S+"),
|
||||
)
|
||||
|
||||
_SENSITIVE_TAG_KEYS = frozenset(
|
||||
{
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"dsn",
|
||||
"sentry_dsn",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_LABELS = (
|
||||
"type:bug",
|
||||
"observability",
|
||||
"status:ready",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectMapping:
|
||||
"""Maps a monitoring project to a Gitea repository."""
|
||||
|
||||
name: str
|
||||
provider: str
|
||||
monitor_base_url: str
|
||||
monitor_org: str
|
||||
monitor_project: str
|
||||
gitea_org: str
|
||||
gitea_repo: str
|
||||
default_labels: tuple[str, ...] = DEFAULT_LABELS
|
||||
environment_filters: tuple[str, ...] = ()
|
||||
severity_threshold: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
prov = (self.provider or "").strip().lower()
|
||||
if prov not in PROVIDERS:
|
||||
raise ControlPlaneError(
|
||||
f"unknown provider '{self.provider}'; expected one of {sorted(PROVIDERS)}"
|
||||
)
|
||||
object.__setattr__(self, "provider", prov)
|
||||
object.__setattr__(self, "monitor_base_url", (self.monitor_base_url or "").rstrip("/"))
|
||||
object.__setattr__(self, "monitor_org", (self.monitor_org or "").strip())
|
||||
object.__setattr__(self, "monitor_project", (self.monitor_project or "").strip())
|
||||
object.__setattr__(self, "gitea_org", (self.gitea_org or "").strip())
|
||||
object.__setattr__(self, "gitea_repo", (self.gitea_repo or "").strip())
|
||||
object.__setattr__(
|
||||
self,
|
||||
"default_labels",
|
||||
tuple(str(x).strip() for x in (self.default_labels or DEFAULT_LABELS) if str(x).strip()),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"provider": self.provider,
|
||||
"monitor_base_url": self.monitor_base_url,
|
||||
"monitor_org": self.monitor_org,
|
||||
"monitor_project": self.monitor_project,
|
||||
"gitea_org": self.gitea_org,
|
||||
"gitea_repo": self.gitea_repo,
|
||||
"default_labels": list(self.default_labels),
|
||||
"environment_filters": list(self.environment_filters),
|
||||
"severity_threshold": self.severity_threshold,
|
||||
}
|
||||
|
||||
def matches_observation(self, obs: dict[str, Any]) -> bool:
|
||||
if (obs.get("provider") or "").strip().lower() != self.provider:
|
||||
return False
|
||||
base = (obs.get("provider_base_url") or obs.get("monitor_base_url") or "").rstrip("/")
|
||||
if base and self.monitor_base_url and base != self.monitor_base_url:
|
||||
return False
|
||||
org = (obs.get("provider_org") or obs.get("monitor_org") or "").strip()
|
||||
if org and self.monitor_org and org != self.monitor_org:
|
||||
return False
|
||||
project = (obs.get("provider_project") or obs.get("monitor_project") or "").strip()
|
||||
if project and self.monitor_project and project != self.monitor_project:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedIncident:
|
||||
provider: str
|
||||
provider_base_url: str
|
||||
provider_org: str
|
||||
provider_project: str
|
||||
provider_issue_id: str
|
||||
provider_short_id: str | None = None
|
||||
provider_permalink: str | None = None
|
||||
fingerprint: str | None = None
|
||||
first_seen: str | None = None
|
||||
last_seen: str | None = None
|
||||
event_count: int | None = None
|
||||
status: str = "open"
|
||||
environment: str | None = None
|
||||
severity: str | None = None
|
||||
culprit: str | None = None
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
tags: dict[str, str] = field(default_factory=dict)
|
||||
gitea_org: str = ""
|
||||
gitea_repo: str = ""
|
||||
default_labels: tuple[str, ...] = DEFAULT_LABELS
|
||||
|
||||
def provider_key(self) -> tuple[str, str, str, str, str]:
|
||||
return (
|
||||
self.provider,
|
||||
_norm_scope(self.provider_base_url),
|
||||
_norm_scope(self.provider_org),
|
||||
_norm_scope(self.provider_project),
|
||||
str(self.provider_issue_id),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": self.provider,
|
||||
"provider_base_url": self.provider_base_url,
|
||||
"provider_org": self.provider_org,
|
||||
"provider_project": self.provider_project,
|
||||
"provider_issue_id": self.provider_issue_id,
|
||||
"provider_short_id": self.provider_short_id,
|
||||
"provider_permalink": self.provider_permalink,
|
||||
"fingerprint": self.fingerprint,
|
||||
"first_seen": self.first_seen,
|
||||
"last_seen": self.last_seen,
|
||||
"event_count": self.event_count,
|
||||
"status": self.status,
|
||||
"environment": self.environment,
|
||||
"severity": self.severity,
|
||||
"culprit": self.culprit,
|
||||
"title": self.title,
|
||||
"summary": self.summary,
|
||||
"tags": dict(self.tags),
|
||||
"gitea_org": self.gitea_org,
|
||||
"gitea_repo": self.gitea_repo,
|
||||
"default_labels": list(self.default_labels),
|
||||
}
|
||||
|
||||
|
||||
def redact_text(value: Any) -> str:
|
||||
"""Redact secret-like material from a string (fail closed to empty)."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value)
|
||||
for pat in _SECRET_PATTERNS:
|
||||
text = pat.sub("[REDACTED]", text)
|
||||
return text
|
||||
|
||||
|
||||
def sanitize_tags(tags: Any) -> dict[str, str]:
|
||||
if not isinstance(tags, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for k, v in tags.items():
|
||||
key = str(k).strip().lower()
|
||||
if not key or key in _SENSITIVE_TAG_KEYS:
|
||||
continue
|
||||
if any(s in key for s in ("token", "secret", "password", "cookie", "auth", "dsn")):
|
||||
continue
|
||||
val = redact_text(v)
|
||||
if "[REDACTED]" in val:
|
||||
continue
|
||||
if len(val) > 200:
|
||||
val = val[:200] + "…"
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
def project_mapping_from_dict(data: dict[str, Any]) -> ProjectMapping:
|
||||
labels = data.get("default_labels") or DEFAULT_LABELS
|
||||
envs = data.get("environment_filters") or ()
|
||||
return ProjectMapping(
|
||||
name=str(data.get("name") or data.get("monitor_project") or "unnamed"),
|
||||
provider=str(data.get("provider") or ""),
|
||||
monitor_base_url=str(data.get("monitor_base_url") or data.get("provider_base_url") or ""),
|
||||
monitor_org=str(data.get("monitor_org") or data.get("provider_org") or ""),
|
||||
monitor_project=str(data.get("monitor_project") or data.get("provider_project") or ""),
|
||||
gitea_org=str(data.get("gitea_org") or ""),
|
||||
gitea_repo=str(data.get("gitea_repo") or ""),
|
||||
default_labels=tuple(labels),
|
||||
environment_filters=tuple(envs),
|
||||
severity_threshold=data.get("severity_threshold"),
|
||||
)
|
||||
|
||||
|
||||
def load_project_mappings(
|
||||
*,
|
||||
config_path: str | None = None,
|
||||
mappings_json: str | None = None,
|
||||
) -> list[ProjectMapping]:
|
||||
"""Load project mappings from JSON env, file, or explicit JSON string.
|
||||
|
||||
Env: ``GITEA_OBSERVABILITY_PROJECTS_JSON`` or path
|
||||
``GITEA_OBSERVABILITY_PROJECTS_FILE``.
|
||||
"""
|
||||
raw: Any = None
|
||||
if mappings_json:
|
||||
raw = json.loads(mappings_json)
|
||||
else:
|
||||
env_json = (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_JSON") or "").strip()
|
||||
path = (
|
||||
(config_path or "").strip()
|
||||
or (os.environ.get("GITEA_OBSERVABILITY_PROJECTS_FILE") or "").strip()
|
||||
)
|
||||
if env_json:
|
||||
raw = json.loads(env_json)
|
||||
elif path and os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
raw = json.load(fh)
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, dict) and "projects" in raw:
|
||||
raw = raw["projects"]
|
||||
if not isinstance(raw, list):
|
||||
raise ControlPlaneError("observability project mappings must be a list")
|
||||
return [project_mapping_from_dict(item) for item in raw if isinstance(item, dict)]
|
||||
|
||||
|
||||
def resolve_mapping(
|
||||
observation: dict[str, Any],
|
||||
mappings: Sequence[ProjectMapping],
|
||||
*,
|
||||
explicit: ProjectMapping | None = None,
|
||||
) -> ProjectMapping | None:
|
||||
if explicit is not None:
|
||||
return explicit
|
||||
matches = [m for m in mappings if m.matches_observation(observation)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise ControlPlaneError(
|
||||
"ambiguous project mapping for observation: "
|
||||
+ ", ".join(m.name for m in matches)
|
||||
+ " (fail closed, #612)"
|
||||
)
|
||||
# Fallback: observation itself may carry gitea targets
|
||||
g_org = (observation.get("gitea_org") or "").strip()
|
||||
g_repo = (observation.get("gitea_repo") or "").strip()
|
||||
provider = (observation.get("provider") or "").strip().lower()
|
||||
if g_org and g_repo and provider in PROVIDERS:
|
||||
return ProjectMapping(
|
||||
name=f"inline-{provider}",
|
||||
provider=provider,
|
||||
monitor_base_url=str(
|
||||
observation.get("provider_base_url")
|
||||
or observation.get("monitor_base_url")
|
||||
or ""
|
||||
),
|
||||
monitor_org=str(
|
||||
observation.get("provider_org") or observation.get("monitor_org") or ""
|
||||
),
|
||||
monitor_project=str(
|
||||
observation.get("provider_project")
|
||||
or observation.get("monitor_project")
|
||||
or ""
|
||||
),
|
||||
gitea_org=g_org,
|
||||
gitea_repo=g_repo,
|
||||
default_labels=tuple(observation.get("default_labels") or DEFAULT_LABELS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_incident(
|
||||
observation: dict[str, Any],
|
||||
mapping: ProjectMapping,
|
||||
) -> NormalizedIncident:
|
||||
"""Normalize + sanitize a raw provider observation (fail closed)."""
|
||||
if not isinstance(observation, dict):
|
||||
raise ControlPlaneError("observation must be a dict (fail closed, #612)")
|
||||
|
||||
provider = (observation.get("provider") or mapping.provider or "").strip().lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise ControlPlaneError(
|
||||
f"unknown provider '{provider}'; expected {sorted(PROVIDERS)} (fail closed)"
|
||||
)
|
||||
if provider != mapping.provider:
|
||||
raise ControlPlaneError(
|
||||
f"observation provider '{provider}' does not match mapping "
|
||||
f"'{mapping.provider}' (fail closed, #612)"
|
||||
)
|
||||
|
||||
issue_id = observation.get("provider_issue_id") or observation.get("id")
|
||||
if issue_id is None or str(issue_id).strip() == "":
|
||||
raise ControlPlaneError(
|
||||
"observation missing provider_issue_id (fail closed, #612)"
|
||||
)
|
||||
|
||||
base = (
|
||||
observation.get("provider_base_url")
|
||||
or observation.get("monitor_base_url")
|
||||
or mapping.monitor_base_url
|
||||
or ""
|
||||
)
|
||||
org = (
|
||||
observation.get("provider_org")
|
||||
or observation.get("monitor_org")
|
||||
or mapping.monitor_org
|
||||
or ""
|
||||
)
|
||||
project = (
|
||||
observation.get("provider_project")
|
||||
or observation.get("monitor_project")
|
||||
or mapping.monitor_project
|
||||
or ""
|
||||
)
|
||||
|
||||
title = redact_text(
|
||||
observation.get("title")
|
||||
or observation.get("culprit")
|
||||
or f"{provider} incident {issue_id}"
|
||||
)
|
||||
meta = observation.get("metadata")
|
||||
meta_value = meta.get("value") if isinstance(meta, dict) else None
|
||||
raw_sum = (
|
||||
observation.get("summary")
|
||||
or observation.get("message")
|
||||
or meta_value
|
||||
or title
|
||||
)
|
||||
summary = redact_text(raw_sum)
|
||||
|
||||
permalink = observation.get("provider_permalink") or observation.get("permalink")
|
||||
if permalink:
|
||||
permalink = redact_text(permalink)
|
||||
if "[REDACTED]" in permalink:
|
||||
permalink = None
|
||||
|
||||
tags = sanitize_tags(observation.get("tags") or {})
|
||||
event_count = observation.get("event_count") or observation.get("count")
|
||||
try:
|
||||
event_count_i = int(event_count) if event_count is not None else None
|
||||
except (TypeError, ValueError):
|
||||
event_count_i = None
|
||||
|
||||
return NormalizedIncident(
|
||||
provider=provider,
|
||||
provider_base_url=str(base).rstrip("/"),
|
||||
provider_org=str(org).strip(),
|
||||
provider_project=str(project).strip(),
|
||||
provider_issue_id=str(issue_id).strip(),
|
||||
provider_short_id=redact_text(observation.get("provider_short_id") or observation.get("shortId") or "")
|
||||
or None,
|
||||
provider_permalink=permalink,
|
||||
fingerprint=redact_text(observation.get("fingerprint") or "") or None,
|
||||
first_seen=str(observation.get("first_seen") or observation.get("firstSeen") or "")
|
||||
or None,
|
||||
last_seen=str(observation.get("last_seen") or observation.get("lastSeen") or "")
|
||||
or None,
|
||||
event_count=event_count_i,
|
||||
status=str(observation.get("status") or "open").strip().lower() or "open",
|
||||
environment=redact_text(observation.get("environment") or "") or None,
|
||||
severity=redact_text(observation.get("severity") or observation.get("level") or "")
|
||||
or None,
|
||||
culprit=redact_text(observation.get("culprit") or "") or None,
|
||||
title=title[:200],
|
||||
summary=summary[:2000],
|
||||
tags=tags,
|
||||
gitea_org=mapping.gitea_org,
|
||||
gitea_repo=mapping.gitea_repo,
|
||||
default_labels=mapping.default_labels,
|
||||
)
|
||||
|
||||
|
||||
def build_gitea_issue_title(inc: NormalizedIncident) -> str:
|
||||
env = f" [{inc.environment}]" if inc.environment else ""
|
||||
sev = f" ({inc.severity})" if inc.severity else ""
|
||||
return redact_text(f"[obs:{inc.provider}]{env}{sev} {inc.title}")[:180]
|
||||
|
||||
|
||||
def build_gitea_issue_body(inc: NormalizedIncident) -> str:
|
||||
"""Sanitized durable issue body (no secrets)."""
|
||||
lines = [
|
||||
"## Observability incident (bridge #612)",
|
||||
"",
|
||||
"<!-- mcp-incident-bridge:v1 -->",
|
||||
f"<!-- provider={inc.provider} issue_id={inc.provider_issue_id} -->",
|
||||
"",
|
||||
f"- **provider:** `{inc.provider}`",
|
||||
f"- **provider_issue_id:** `{inc.provider_issue_id}`",
|
||||
]
|
||||
if inc.provider_short_id:
|
||||
lines.append(f"- **provider_short_id:** `{inc.provider_short_id}`")
|
||||
if inc.provider_permalink:
|
||||
lines.append(f"- **provider_url:** {inc.provider_permalink}")
|
||||
lines.extend(
|
||||
[
|
||||
f"- **provider_org/project:** `{inc.provider_org}` / `{inc.provider_project}`",
|
||||
f"- **base_url:** `{inc.provider_base_url}`",
|
||||
f"- **fingerprint:** `{inc.fingerprint or ''}`",
|
||||
f"- **first_seen:** `{inc.first_seen or ''}`",
|
||||
f"- **last_seen:** `{inc.last_seen or ''}`",
|
||||
f"- **event_count:** `{inc.event_count if inc.event_count is not None else ''}`",
|
||||
f"- **environment:** `{inc.environment or ''}`",
|
||||
f"- **severity:** `{inc.severity or ''}`",
|
||||
f"- **culprit:** `{inc.culprit or ''}`",
|
||||
f"- **status:** `{inc.status}`",
|
||||
"",
|
||||
"### Summary",
|
||||
"",
|
||||
redact_text(inc.summary) or "(no summary)",
|
||||
"",
|
||||
"### Safe tags",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if inc.tags:
|
||||
for k, v in sorted(inc.tags.items()):
|
||||
lines.append(f"- `{k}`: `{v}`")
|
||||
else:
|
||||
lines.append("- (none)")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### Canonical next action",
|
||||
"",
|
||||
"Author: investigate and fix under normal Gitea workflow. "
|
||||
"Allocator may select this issue as ordinary Gitea work "
|
||||
"(never as a raw provider incident).",
|
||||
"",
|
||||
"### Bridge notes",
|
||||
"",
|
||||
"- Raw Sentry/GlitchTip incidents are **not** assignable work items.",
|
||||
"- Gitea remains the durable work record; DB stores `incident_links` only.",
|
||||
"- Provider tokens must never appear in this issue.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _link_conflict(existing: dict[str, Any], inc: NormalizedIncident) -> str | None:
|
||||
"""Fail closed if existing link targets a different Gitea issue/repo."""
|
||||
eg_org = str(existing.get("gitea_org") or "")
|
||||
eg_repo = str(existing.get("gitea_repo") or "")
|
||||
eg_num = existing.get("gitea_issue_number")
|
||||
if eg_org and eg_org != inc.gitea_org:
|
||||
return (
|
||||
f"existing link points to org '{eg_org}', mapping wants "
|
||||
f"'{inc.gitea_org}' (fail closed, #612)"
|
||||
)
|
||||
if eg_repo and eg_repo != inc.gitea_repo:
|
||||
return (
|
||||
f"existing link points to repo '{eg_repo}', mapping wants "
|
||||
f"'{inc.gitea_repo}' (fail closed, #612)"
|
||||
)
|
||||
# fingerprint conflict when both present and disagree
|
||||
ef = (existing.get("fingerprint") or "").strip()
|
||||
nf = (inc.fingerprint or "").strip()
|
||||
if ef and nf and ef != nf:
|
||||
# Same provider issue id with different fingerprints is ambiguous.
|
||||
return (
|
||||
f"fingerprint conflict for provider issue {inc.provider_issue_id}: "
|
||||
f"link has '{ef}', observation has '{nf}' (fail closed, #612)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
CreateIssueFn = Callable[[str, str, list[str], str, str], dict[str, Any]]
|
||||
# create_issue_fn(title, body, labels, gitea_org, gitea_repo) -> {"number": int, ...}
|
||||
|
||||
|
||||
def reconcile_incident(
|
||||
db: ControlPlaneDB | None,
|
||||
*,
|
||||
observation: dict[str, Any],
|
||||
mappings: Sequence[ProjectMapping] | None = None,
|
||||
mapping: ProjectMapping | None = None,
|
||||
apply: bool = False,
|
||||
create_issue_fn: CreateIssueFn | None = None,
|
||||
force_gitea_issue_number: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reconcile one observation into incident_links + optional Gitea issue.
|
||||
|
||||
*apply=False* (default): preview only — no DB mutation, no Gitea mutation.
|
||||
*apply=True*: upsert link; create Gitea issue when none linked (requires
|
||||
``create_issue_fn``) or use ``force_gitea_issue_number`` for explicit link.
|
||||
|
||||
Never creates control-plane ``work_items`` for raw incidents.
|
||||
"""
|
||||
base: dict[str, Any] = {
|
||||
"success": False,
|
||||
"apply": bool(apply),
|
||||
"outcome": OUTCOME_NO_ACTION,
|
||||
"performed": False,
|
||||
"gitea_mutated": False,
|
||||
"db_mutated": False,
|
||||
"raw_incident_assignable": False,
|
||||
"work_item_kind_would_be": None,
|
||||
"reasons": [],
|
||||
"skipped": None,
|
||||
"incident": None,
|
||||
"existing_link": None,
|
||||
"gitea_issue": None,
|
||||
"action": None,
|
||||
"mapping": None,
|
||||
"substrate": "control_plane_db.incident_links",
|
||||
"durable_work_system": "gitea_issues",
|
||||
}
|
||||
|
||||
if db is None:
|
||||
base["reasons"] = [
|
||||
"control-plane DB substrate unavailable (fail closed, #613/#612)"
|
||||
]
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
return base
|
||||
|
||||
try:
|
||||
maps = list(mappings or [])
|
||||
resolved = resolve_mapping(observation, maps, explicit=mapping)
|
||||
if resolved is None:
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [
|
||||
"no project mapping for observation (fail closed, #612); "
|
||||
"configure GITEA_OBSERVABILITY_PROJECTS_JSON or pass mapping"
|
||||
]
|
||||
return base
|
||||
base["mapping"] = resolved.as_dict()
|
||||
inc = normalize_incident(observation, resolved)
|
||||
base["incident"] = inc.as_dict()
|
||||
except (ControlPlaneError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [str(exc)]
|
||||
return base
|
||||
|
||||
# Environment filter (optional)
|
||||
if resolved.environment_filters and inc.environment:
|
||||
allowed = {e.lower() for e in resolved.environment_filters}
|
||||
if inc.environment.lower() not in allowed:
|
||||
base["outcome"] = OUTCOME_NO_ACTION
|
||||
base["reasons"] = [
|
||||
f"environment '{inc.environment}' not in filters "
|
||||
f"{sorted(allowed)}; skip (policy)"
|
||||
]
|
||||
base["success"] = True
|
||||
base["action"] = "skip_environment_filter"
|
||||
return base
|
||||
|
||||
existing = db.get_incident_link_by_provider(
|
||||
provider=inc.provider,
|
||||
provider_issue_id=inc.provider_issue_id,
|
||||
provider_base_url=inc.provider_base_url,
|
||||
provider_org=inc.provider_org,
|
||||
provider_project=inc.provider_project,
|
||||
)
|
||||
base["existing_link"] = existing
|
||||
|
||||
if existing:
|
||||
conflict = _link_conflict(existing, inc)
|
||||
if conflict:
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [conflict]
|
||||
return base
|
||||
|
||||
title = build_gitea_issue_title(inc)
|
||||
body = build_gitea_issue_body(inc)
|
||||
labels = list(inc.default_labels)
|
||||
if inc.provider and f"{inc.provider}" not in labels:
|
||||
labels.append(inc.provider)
|
||||
if "observability" not in labels:
|
||||
labels.append("observability")
|
||||
|
||||
preview_issue = {
|
||||
"title": title,
|
||||
"body_preview": body[:500] + ("…" if len(body) > 500 else ""),
|
||||
"labels": labels,
|
||||
"gitea_org": inc.gitea_org,
|
||||
"gitea_repo": inc.gitea_repo,
|
||||
"would_create": existing is None and force_gitea_issue_number is None,
|
||||
"would_reuse_issue": int(existing["gitea_issue_number"])
|
||||
if existing
|
||||
else force_gitea_issue_number,
|
||||
}
|
||||
base["gitea_issue_preview"] = preview_issue
|
||||
|
||||
if not apply:
|
||||
base["success"] = True
|
||||
base["outcome"] = OUTCOME_PREVIEW
|
||||
base["action"] = (
|
||||
"preview_reuse_link" if existing else "preview_create_issue"
|
||||
)
|
||||
base["reasons"] = [
|
||||
"dry-run only (apply=false); no Gitea mutation and no DB write — "
|
||||
"call again with apply=true to create/link"
|
||||
]
|
||||
if existing:
|
||||
base["gitea_issue"] = {
|
||||
"number": existing.get("gitea_issue_number"),
|
||||
"org": existing.get("gitea_org"),
|
||||
"repo": existing.get("gitea_repo"),
|
||||
}
|
||||
# Prove we never treat this as work_item kind incident
|
||||
base["raw_incident_assignable"] = False
|
||||
base["work_item_kind_would_be"] = "issue" # only after Gitea issue exists
|
||||
return base
|
||||
|
||||
# --- apply path ---
|
||||
issue_number: int | None = None
|
||||
created = False
|
||||
if existing:
|
||||
issue_number = int(existing["gitea_issue_number"])
|
||||
action = "updated_existing_link"
|
||||
outcome = OUTCOME_UPDATED
|
||||
elif force_gitea_issue_number is not None:
|
||||
issue_number = int(force_gitea_issue_number)
|
||||
action = "link_explicit_issue"
|
||||
outcome = OUTCOME_LINKED
|
||||
else:
|
||||
if create_issue_fn is None:
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [
|
||||
"apply=true requires create_issue_fn when no existing link "
|
||||
"(fail closed, #612)"
|
||||
]
|
||||
return base
|
||||
try:
|
||||
created_res = create_issue_fn(
|
||||
title, body, labels, inc.gitea_org, inc.gitea_repo
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [
|
||||
f"Gitea issue creation failed: {redact_text(exc)} (fail closed)"
|
||||
]
|
||||
return base
|
||||
number = created_res.get("number") if isinstance(created_res, dict) else None
|
||||
if number is None:
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [
|
||||
"Gitea issue creation returned no issue number (fail closed, #612)"
|
||||
]
|
||||
base["create_result"] = created_res
|
||||
return base
|
||||
issue_number = int(number)
|
||||
created = True
|
||||
action = "created_gitea_issue"
|
||||
outcome = OUTCOME_CREATED
|
||||
base["gitea_mutated"] = True
|
||||
base["create_result"] = {
|
||||
k: created_res.get(k)
|
||||
for k in ("number", "success", "performed", "reasons")
|
||||
if isinstance(created_res, dict)
|
||||
}
|
||||
|
||||
assert issue_number is not None
|
||||
try:
|
||||
link = db.upsert_incident_link(
|
||||
provider=inc.provider,
|
||||
provider_issue_id=inc.provider_issue_id,
|
||||
gitea_org=inc.gitea_org,
|
||||
gitea_repo=inc.gitea_repo,
|
||||
gitea_issue_number=issue_number,
|
||||
provider_base_url=inc.provider_base_url,
|
||||
provider_org=inc.provider_org,
|
||||
provider_project=inc.provider_project,
|
||||
provider_short_id=inc.provider_short_id,
|
||||
provider_permalink=inc.provider_permalink,
|
||||
fingerprint=inc.fingerprint,
|
||||
first_seen=inc.first_seen,
|
||||
last_seen=inc.last_seen,
|
||||
event_count=inc.event_count,
|
||||
status=inc.status if not created else "open",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
base["outcome"] = OUTCOME_BLOCKED
|
||||
base["reasons"] = [
|
||||
f"incident_links upsert failed: {redact_text(exc)} (fail closed, #613)"
|
||||
]
|
||||
base["gitea_issue"] = {
|
||||
"number": issue_number,
|
||||
"org": inc.gitea_org,
|
||||
"repo": inc.gitea_repo,
|
||||
"created": created,
|
||||
}
|
||||
return base
|
||||
|
||||
base["success"] = True
|
||||
base["performed"] = True
|
||||
base["db_mutated"] = True
|
||||
base["outcome"] = outcome
|
||||
base["action"] = action
|
||||
base["gitea_issue"] = {
|
||||
"number": issue_number,
|
||||
"org": inc.gitea_org,
|
||||
"repo": inc.gitea_repo,
|
||||
"created": created,
|
||||
}
|
||||
base["link"] = {
|
||||
k: link.get(k)
|
||||
for k in (
|
||||
"link_id",
|
||||
"provider",
|
||||
"provider_issue_id",
|
||||
"gitea_org",
|
||||
"gitea_repo",
|
||||
"gitea_issue_number",
|
||||
"fingerprint",
|
||||
"event_count",
|
||||
"status",
|
||||
"last_sync_at",
|
||||
)
|
||||
}
|
||||
base["reasons"] = [
|
||||
(
|
||||
f"reused Gitea issue #{issue_number} for provider "
|
||||
f"{inc.provider}:{inc.provider_issue_id}"
|
||||
if not created
|
||||
else f"created Gitea issue #{issue_number} and stored incident_links row"
|
||||
),
|
||||
"raw provider incident is not an assignable work_item "
|
||||
f"(WORK_KINDS={sorted(WORK_KINDS)})",
|
||||
]
|
||||
base["raw_incident_assignable"] = False
|
||||
base["work_item_kind_would_be"] = "issue"
|
||||
base["allocator_visible_as"] = {
|
||||
"kind": "issue",
|
||||
"number": issue_number,
|
||||
"org": inc.gitea_org,
|
||||
"repo": inc.gitea_repo,
|
||||
"note": "allocator selects normal Gitea issues only (#600/#612)",
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def assert_not_raw_incident_work_item(kind: str) -> None:
|
||||
"""Guard used by tests / callers: incidents must not enter WORK_KINDS."""
|
||||
k = (kind or "").strip().lower()
|
||||
if k not in WORK_KINDS:
|
||||
if k in ("sentry_incident", "glitchtip_incident", "incident", "observation"):
|
||||
raise ControlPlaneError(
|
||||
f"kind '{k}' is not assignable; bridge must create Gitea issues first (#612)"
|
||||
)
|
||||
raise ControlPlaneError(f"kind '{k}' is not assignable")
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Controller issue-acceptance gate helpers (#500).
|
||||
|
||||
Pure validation for controller acceptance comments and final-report claims
|
||||
that an issue is complete. Does not post comments or close issues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ACCEPTANCE_HEADING = "controller issue acceptance"
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
"STATE",
|
||||
"WHO_IS_NEXT",
|
||||
"NEXT_ACTION",
|
||||
"NEXT_PROMPT",
|
||||
"ISSUE",
|
||||
"MERGED_PR",
|
||||
"MERGE_COMMIT",
|
||||
"ACCEPTANCE_CRITERIA_CHECKED",
|
||||
"VALIDATION_REVIEWED",
|
||||
"CONTROLLER_DECISION",
|
||||
"WHY",
|
||||
)
|
||||
|
||||
ACCEPTED_STATES = frozenset({"accepted"})
|
||||
REJECTION_STATES = frozenset({
|
||||
"more-work-required",
|
||||
"more_work_required",
|
||||
"needs-tests",
|
||||
"needs_tests",
|
||||
"needs-docs",
|
||||
"needs_docs",
|
||||
"needs-feature-enhancement",
|
||||
"needs_feature_enhancement",
|
||||
"needs-follow-up-issue",
|
||||
"needs_follow_up_issue",
|
||||
"blocked",
|
||||
})
|
||||
|
||||
ALLOWED_NEXT_ACTORS = frozenset({
|
||||
"controller",
|
||||
"author",
|
||||
"reviewer",
|
||||
"merger",
|
||||
"reconciler",
|
||||
"user",
|
||||
})
|
||||
|
||||
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
|
||||
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
|
||||
_ISSUE_REF_RE = re.compile(r"#\d+")
|
||||
_PR_REF_RE = re.compile(r"#\d+")
|
||||
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
|
||||
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
|
||||
|
||||
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
|
||||
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
|
||||
r"\bissue\s+acceptance\s*:\s*accepted\b|"
|
||||
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_ONLY_COMPLETE_RE = re.compile(
|
||||
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
|
||||
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_ACCEPTANCE_RE = re.compile(
|
||||
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def render_controller_acceptance_template() -> str:
|
||||
"""Return the canonical controller issue-acceptance comment template."""
|
||||
return """## Controller Issue Acceptance
|
||||
|
||||
STATE:
|
||||
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
|
||||
|
||||
WHO_IS_NEXT:
|
||||
<author | reviewer | merger | reconciler | controller | user>
|
||||
|
||||
NEXT_ACTION:
|
||||
<one sentence>
|
||||
|
||||
NEXT_PROMPT:
|
||||
<paste-ready prompt for the next LLM>
|
||||
|
||||
ISSUE:
|
||||
#...
|
||||
|
||||
MERGED_PR:
|
||||
#...
|
||||
|
||||
MERGE_COMMIT:
|
||||
<40-character SHA>
|
||||
|
||||
ACCEPTANCE_CRITERIA_CHECKED:
|
||||
- [x] ...
|
||||
- [ ] ...
|
||||
|
||||
VALIDATION_REVIEWED:
|
||||
<tests/proofs reviewed>
|
||||
|
||||
CONTROLLER_DECISION:
|
||||
<accepted or rejected>
|
||||
|
||||
WHY:
|
||||
<reasoning>
|
||||
|
||||
MISSING_WORK:
|
||||
<none, or exact missing work>
|
||||
|
||||
FOLLOW_UP_ISSUES:
|
||||
<none, or issue list to create>
|
||||
|
||||
BLOCKERS:
|
||||
<none, or exact blockers>
|
||||
|
||||
LAST_UPDATED_BY:
|
||||
<identity/profile/date>
|
||||
"""
|
||||
|
||||
|
||||
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
|
||||
"""Return upper-case labeled fields from a controller acceptance block."""
|
||||
fields: dict[str, str] = {}
|
||||
current_key: str | None = None
|
||||
for line in (text or "").splitlines():
|
||||
match = _FIELD_RE.match(line)
|
||||
if match:
|
||||
current_key = match.group(1).strip().upper().replace(" ", "_")
|
||||
fields[current_key] = match.group(2).strip()
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if current_key and stripped:
|
||||
existing = fields.get(current_key, "")
|
||||
fields[current_key] = (
|
||||
f"{existing}\n{stripped}" if existing else stripped
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
def contains_acceptance_block(text: str | None) -> bool:
|
||||
return ACCEPTANCE_HEADING in (text or "").lower()
|
||||
|
||||
|
||||
def _empty_or_placeholder(value: str | None) -> bool:
|
||||
value = (value or "").strip().lower()
|
||||
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."}
|
||||
|
||||
|
||||
def _normalize_state(value: str | None) -> str:
|
||||
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
|
||||
|
||||
|
||||
def validate_controller_acceptance_comment(text: str | None) -> dict:
|
||||
"""Validate a controller issue-acceptance comment."""
|
||||
body = text or ""
|
||||
if not contains_acceptance_block(body):
|
||||
return {
|
||||
"valid": False,
|
||||
"fields": {},
|
||||
"reasons": ["missing Controller Issue Acceptance heading"],
|
||||
}
|
||||
|
||||
fields = extract_acceptance_fields(body)
|
||||
reasons: list[str] = []
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if _empty_or_placeholder(fields.get(field)):
|
||||
reasons.append(f"missing required controller acceptance field: {field}")
|
||||
|
||||
state = _normalize_state(fields.get("STATE"))
|
||||
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
|
||||
reasons.append(
|
||||
"STATE must be accepted or a rejection path "
|
||||
"(more-work-required, needs-tests, needs-docs, "
|
||||
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
|
||||
)
|
||||
|
||||
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
|
||||
if actor and actor not in ALLOWED_NEXT_ACTORS:
|
||||
reasons.append(
|
||||
"WHO_IS_NEXT must be one of: "
|
||||
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
|
||||
)
|
||||
|
||||
if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
|
||||
reasons.append("ISSUE must cite an issue number (#N)")
|
||||
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
|
||||
reasons.append("MERGED_PR must cite a merged PR number (#N)")
|
||||
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
|
||||
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
|
||||
|
||||
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
|
||||
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
|
||||
)
|
||||
|
||||
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
|
||||
if state in ACCEPTED_STATES:
|
||||
if decision not in {"accepted", "accept"}:
|
||||
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
|
||||
if not _CHECKED_ITEM_RE.search(criteria):
|
||||
reasons.append(
|
||||
"accepted STATE requires at least one checked acceptance criterion"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("WHY")):
|
||||
reasons.append("accepted STATE requires WHY with acceptance rationale")
|
||||
elif state in REJECTION_STATES:
|
||||
if decision not in {"rejected", "reject", "more_work_required"}:
|
||||
reasons.append(
|
||||
"rejection STATE requires CONTROLLER_DECISION: rejected"
|
||||
)
|
||||
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
|
||||
reasons.append(
|
||||
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
|
||||
)
|
||||
missing = fields.get("MISSING_WORK") or ""
|
||||
if _empty_or_placeholder(missing):
|
||||
reasons.append(
|
||||
"rejection STATE requires MISSING_WORK describing what is still needed"
|
||||
)
|
||||
|
||||
return {
|
||||
"valid": not reasons,
|
||||
"fields": fields,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def claims_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text claims an issue is complete/accepted."""
|
||||
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_merge_only_issue_complete(text: str | None) -> bool:
|
||||
"""Return True when text treats PR merge as issue completion."""
|
||||
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
|
||||
|
||||
|
||||
def claims_controller_acceptance_update(text: str | None) -> bool:
|
||||
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def notes_controller_acceptance_pending(text: str | None) -> bool:
|
||||
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
|
||||
|
||||
|
||||
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
|
||||
"""Validate issue-completion and controller-acceptance claims in final reports."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
|
||||
complete_claim = claims_issue_complete(text)
|
||||
merge_only = claims_merge_only_issue_complete(text)
|
||||
acceptance_claim = claims_controller_acceptance_update(text)
|
||||
pending_noted = notes_controller_acceptance_pending(text)
|
||||
has_block = contains_acceptance_block(text)
|
||||
|
||||
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
|
||||
reasons.append(
|
||||
"final report treats PR merge as issue completion without controller acceptance proof"
|
||||
)
|
||||
|
||||
if complete_claim and not pending_noted:
|
||||
if not has_block:
|
||||
reasons.append(
|
||||
"final report claims issue complete but includes no Controller Issue Acceptance block"
|
||||
)
|
||||
else:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
else:
|
||||
state = _normalize_state(result["fields"].get("STATE"))
|
||||
if state not in ACCEPTED_STATES:
|
||||
reasons.append(
|
||||
"final report claims issue complete but controller STATE is not accepted"
|
||||
)
|
||||
|
||||
if acceptance_claim and has_block:
|
||||
result = validate_controller_acceptance_comment(text)
|
||||
if not result["valid"]:
|
||||
reasons.extend(result["reasons"])
|
||||
|
||||
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"valid": not reasons,
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Issue claim heartbeat leases and stale-claim reconciliation (#268).
|
||||
|
||||
Structured issue-thread comments prove live ownership beyond the
|
||||
``status:in-progress`` label alone. Queue inventory can classify claims as
|
||||
active, stale, reclaimable, PR-backed, or phantom.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
MARKER = "<!-- gitea-issue-claim-heartbeat:v1 -->"
|
||||
IN_PROGRESS_LABEL = "status:in-progress"
|
||||
|
||||
_KIND_CLAIM = "claim"
|
||||
_KIND_PROGRESS = "progress"
|
||||
_KIND_CLEANUP = "cleanup"
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*-\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def format_heartbeat_body(
|
||||
*,
|
||||
kind: str,
|
||||
issue_number: int,
|
||||
branch: str,
|
||||
phase: str,
|
||||
profile: str | None = None,
|
||||
pr: str = "none",
|
||||
next_action: str = "none",
|
||||
blocker: str = "none",
|
||||
) -> str:
|
||||
"""Return a structured, machine-parseable issue comment body."""
|
||||
profile_value = (profile or "unknown").strip() or "unknown"
|
||||
lines = [
|
||||
MARKER,
|
||||
"**Issue claim heartbeat**",
|
||||
f"- kind: {kind}",
|
||||
f"- issue: #{issue_number}",
|
||||
f"- branch: {branch}",
|
||||
f"- phase: {phase}",
|
||||
f"- profile: {profile_value}",
|
||||
f"- pr: {pr}",
|
||||
f"- blocker: {blocker}",
|
||||
f"- next_action: {next_action}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_heartbeat_comment(body: str) -> dict[str, Any] | None:
|
||||
"""Parse one structured heartbeat comment, or None when not a heartbeat."""
|
||||
text = body or ""
|
||||
if MARKER not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
if not fields:
|
||||
return None
|
||||
issue_raw = fields.get("issue", "")
|
||||
issue_digits = re.sub(r"[^\d]", "", issue_raw)
|
||||
issue_number = int(issue_digits) if issue_digits.isdigit() else None
|
||||
return {
|
||||
"kind": fields.get("kind"),
|
||||
"issue_number": issue_number,
|
||||
"branch": fields.get("branch"),
|
||||
"phase": fields.get("phase"),
|
||||
"profile": fields.get("profile"),
|
||||
"pr": fields.get("pr"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"next_action": fields.get("next_action"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def extract_issue_heartbeats(
|
||||
comments: list[dict],
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return parsed heartbeats newest-last, optionally filtered to *issue_number*."""
|
||||
heartbeats: list[dict] = []
|
||||
for comment in comments or []:
|
||||
parsed = parse_heartbeat_comment(comment.get("body") or "")
|
||||
if not parsed:
|
||||
continue
|
||||
if issue_number is not None and parsed.get("issue_number") != issue_number:
|
||||
continue
|
||||
heartbeats.append(
|
||||
{
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login")
|
||||
or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
}
|
||||
)
|
||||
return heartbeats
|
||||
|
||||
|
||||
def issue_has_in_progress_label(issue: dict) -> bool:
|
||||
labels = issue.get("labels") or []
|
||||
for label in labels:
|
||||
name = label if isinstance(label, str) else label.get("name")
|
||||
if name == IN_PROGRESS_LABEL:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
pattern = f"issue-{issue_number}"
|
||||
closes = f"closes #{issue_number}"
|
||||
fixes = f"fixes #{issue_number}"
|
||||
for pr in open_prs or []:
|
||||
head = (pr.get("head") or {}).get("ref") or ""
|
||||
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
|
||||
if pattern in head.lower():
|
||||
return pr
|
||||
if closes in text or fixes in text:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
def _matching_branch_names(issue_number: int, branch_names: list[str]) -> list[str]:
|
||||
pattern = f"issue-{issue_number}"
|
||||
return [name for name in branch_names if pattern in (name or "").lower()]
|
||||
|
||||
|
||||
def classify_issue_claim(
|
||||
*,
|
||||
issue: dict,
|
||||
comments: list[dict],
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify one issue's claim state from labels, heartbeats, PRs, and branches."""
|
||||
issue_number = int(issue.get("number") or 0)
|
||||
open_prs = open_prs or []
|
||||
branch_names = branch_names or []
|
||||
current = now or datetime.now(timezone.utc)
|
||||
|
||||
has_label = issue_has_in_progress_label(issue)
|
||||
heartbeats = extract_issue_heartbeats(comments, issue_number=issue_number)
|
||||
latest = heartbeats[-1] if heartbeats else None
|
||||
latest_at = _parse_timestamp(
|
||||
(latest or {}).get("updated_at") or (latest or {}).get("created_at")
|
||||
)
|
||||
age_minutes = None
|
||||
if latest_at:
|
||||
age_minutes = int((current - latest_at).total_seconds() // 60)
|
||||
|
||||
linked_pr = _linked_open_pr(issue_number, open_prs)
|
||||
matching_branches = _matching_branch_names(issue_number, branch_names)
|
||||
|
||||
if not has_label:
|
||||
status = "not_claimed"
|
||||
reasons = ["issue lacks status:in-progress label"]
|
||||
elif linked_pr:
|
||||
status = "awaiting_review"
|
||||
reasons = [f"open PR #{linked_pr.get('number')} covers this issue"]
|
||||
elif not heartbeats:
|
||||
status = "phantom"
|
||||
reasons = [
|
||||
"status:in-progress label present without structured claim heartbeat"
|
||||
]
|
||||
elif latest_at is None:
|
||||
status = "phantom"
|
||||
reasons = ["heartbeat comments present but timestamps could not be parsed"]
|
||||
elif age_minutes is not None and age_minutes <= heartbeat_lease_minutes:
|
||||
status = "active"
|
||||
reasons = [f"heartbeat age {age_minutes}m within {heartbeat_lease_minutes}m lease"]
|
||||
elif matching_branches and age_minutes is not None and age_minutes <= reclaim_after_minutes:
|
||||
status = "active"
|
||||
reasons = [
|
||||
f"matching branch(es) {', '.join(matching_branches)} with heartbeat "
|
||||
f"age {age_minutes}m"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > reclaim_after_minutes and not matching_branches:
|
||||
status = "reclaimable"
|
||||
reasons = [
|
||||
f"no heartbeat within {reclaim_after_minutes}m and no matching branch"
|
||||
]
|
||||
elif age_minutes is not None and age_minutes > heartbeat_lease_minutes:
|
||||
status = "stale"
|
||||
reasons = [
|
||||
f"heartbeat age {age_minutes}m exceeds {heartbeat_lease_minutes}m lease"
|
||||
]
|
||||
else:
|
||||
status = "active"
|
||||
reasons = ["claim has structured heartbeat proof"]
|
||||
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"title": issue.get("title"),
|
||||
"status": status,
|
||||
"has_in_progress_label": has_label,
|
||||
"heartbeat_count": len(heartbeats),
|
||||
"latest_heartbeat": latest,
|
||||
"heartbeat_age_minutes": age_minutes,
|
||||
"linked_open_pr": linked_pr.get("number") if linked_pr else None,
|
||||
"matching_branches": matching_branches,
|
||||
"reasons": reasons,
|
||||
"reclaimable": status == "reclaimable",
|
||||
"stale": status in {"stale", "phantom", "reclaimable"},
|
||||
}
|
||||
|
||||
|
||||
def build_claim_inventory(
|
||||
*,
|
||||
issues: list[dict],
|
||||
comments_by_issue: dict[int, list[dict]],
|
||||
open_prs: list[dict],
|
||||
branch_names: list[str],
|
||||
now: datetime | None = None,
|
||||
heartbeat_lease_minutes: int = 30,
|
||||
reclaim_after_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a queue inventory report for in-progress issue claims."""
|
||||
entries: list[dict] = []
|
||||
for issue in issues or []:
|
||||
if not issue_has_in_progress_label(issue):
|
||||
continue
|
||||
number = int(issue["number"])
|
||||
entry = classify_issue_claim(
|
||||
issue=issue,
|
||||
comments=comments_by_issue.get(number, []),
|
||||
open_prs=open_prs,
|
||||
branch_names=branch_names,
|
||||
now=now,
|
||||
heartbeat_lease_minutes=heartbeat_lease_minutes,
|
||||
reclaim_after_minutes=reclaim_after_minutes,
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
counts[entry["status"]] = counts.get(entry["status"], 0) + 1
|
||||
|
||||
return {
|
||||
"entries": entries,
|
||||
"counts": counts,
|
||||
"heartbeat_lease_minutes": heartbeat_lease_minutes,
|
||||
"reclaim_after_minutes": reclaim_after_minutes,
|
||||
"in_progress_total": len(entries),
|
||||
}
|
||||
|
||||
|
||||
def build_cleanup_plan(inventory: dict[str, Any]) -> list[dict]:
|
||||
"""Return stale/reclaimable/phantom claims that may be cleaned up."""
|
||||
plan: list[dict] = []
|
||||
for entry in inventory.get("entries") or []:
|
||||
if entry.get("status") in {"reclaimable", "phantom"}:
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "remove_status_in_progress_and_comment",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
elif entry.get("status") == "stale" and not entry.get("linked_open_pr"):
|
||||
plan.append(
|
||||
{
|
||||
"issue_number": entry["issue_number"],
|
||||
"status": entry["status"],
|
||||
"action": "report_only",
|
||||
"reasons": list(entry.get("reasons") or []),
|
||||
}
|
||||
)
|
||||
return plan
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Pre-create issue content gate (#582).
|
||||
|
||||
Blocks issue creation when the title/body is a *vague reference* to
|
||||
out-of-band content the LLM cannot prove it has ("the drafted issue",
|
||||
"the prepared issue", "the issue we discussed", "the previous draft")
|
||||
with no durable source pointer.
|
||||
|
||||
The rule from #582: an LLM must not fabricate an issue from stale chat
|
||||
memory. When the requested issue content is a vague reference and no
|
||||
durable source pointer (existing issue/PR/comment, checked-in file, URL,
|
||||
or scratchpad path) is present, the gate fails closed and the caller must
|
||||
return BLOCKED + DIAGNOSE naming the exact vague/missing fields.
|
||||
|
||||
This gate intentionally does NOT require a non-empty body: title-only
|
||||
issue creation remains allowed for callers that explicitly want it. It
|
||||
only blocks content that is a placeholder reference to something the
|
||||
current context does not contain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# Vague reference phrases that point at out-of-band draft content. Stored
|
||||
# normalized (lowercase, punctuation-stripped, whitespace-collapsed) so they
|
||||
# can be matched against normalized field text.
|
||||
VAGUE_REFERENCE_PHRASES: tuple[str, ...] = (
|
||||
"the drafted issue",
|
||||
"drafted issue",
|
||||
"the prepared issue",
|
||||
"prepared issue",
|
||||
"the issue we discussed",
|
||||
"the issue we talked about",
|
||||
"the one we discussed",
|
||||
"the previous draft",
|
||||
"previous draft",
|
||||
"the draft above",
|
||||
"the issue above",
|
||||
"as discussed",
|
||||
"as we discussed",
|
||||
"as previously discussed",
|
||||
"per our discussion",
|
||||
"per our conversation",
|
||||
"per our chat",
|
||||
"see above",
|
||||
"same as before",
|
||||
"the issue from earlier",
|
||||
"the issue i drafted",
|
||||
"the issue you drafted",
|
||||
)
|
||||
|
||||
# A field that only restates a vague phrase (optionally with a leading verb
|
||||
# such as "create"/"add"/"open"/"file") is still a vague reference.
|
||||
_LEADING_VERBS = ("create", "add", "open", "file", "make", "please", "the")
|
||||
|
||||
# Durable source pointers: if any of these appears, the content points at a
|
||||
# retrievable source of truth and is NOT treated as a fabricated reference.
|
||||
_DURABLE_SOURCE_REGEXES: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"#\d+"), # #402
|
||||
re.compile(r"\b(?:issue|pull|pr)\s+#?\d+", re.I), # issue 402 / PR #17
|
||||
re.compile(r"\bcomment\s+#?\d+", re.I), # comment 8155
|
||||
re.compile(r"https?://\S+", re.I), # URL
|
||||
re.compile( # checked-in file
|
||||
r"[\w./-]+\.(?:py|md|json|txt|sh|ya?ml|toml|cfg|ini|rst)\b", re.I
|
||||
),
|
||||
re.compile(r"\bscratchpad\b", re.I), # scratchpad path
|
||||
re.compile(r"(?:^|\s)/[\w./-]+"), # absolute path
|
||||
)
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Lowercase, punctuation-stripped, whitespace-collapsed text."""
|
||||
norm = unicodedata.normalize("NFKC", (text or "").strip().lower())
|
||||
norm = re.sub(r"[^\w\s]", " ", norm)
|
||||
norm = re.sub(r"\s+", " ", norm).strip()
|
||||
return norm
|
||||
|
||||
|
||||
def has_durable_source_pointer(text: str) -> bool:
|
||||
"""True when the raw text references a durable, retrievable source."""
|
||||
raw = text or ""
|
||||
return any(rx.search(raw) for rx in _DURABLE_SOURCE_REGEXES)
|
||||
|
||||
|
||||
def find_vague_reference(text: str) -> str | None:
|
||||
"""Return the vague phrase a field is dominated by, else ``None``.
|
||||
|
||||
A field is a vague reference only when it contains a known vague phrase,
|
||||
carries no durable source pointer, and the phrase dominates the field
|
||||
(the field is essentially just the phrase). Long, specific bodies that
|
||||
merely contain "as discussed" in passing are not blocked.
|
||||
"""
|
||||
if has_durable_source_pointer(text):
|
||||
return None
|
||||
norm = normalize_text(text)
|
||||
if not norm:
|
||||
return None
|
||||
stripped = norm
|
||||
for verb in _LEADING_VERBS:
|
||||
if stripped.startswith(verb + " "):
|
||||
stripped = stripped[len(verb) + 1:]
|
||||
for phrase in VAGUE_REFERENCE_PHRASES:
|
||||
if phrase in norm and len(stripped) <= len(phrase) + 12:
|
||||
return phrase
|
||||
return None
|
||||
|
||||
|
||||
def assess_issue_content(
|
||||
title: str,
|
||||
body: str = "",
|
||||
*,
|
||||
allow_incomplete: bool = False,
|
||||
) -> dict:
|
||||
"""Evaluate whether proposed issue content is durable enough to create.
|
||||
|
||||
Returns a dict with ``complete`` (bool), ``missing_fields``,
|
||||
``vague_fields``, ``reasons``, ``diagnose`` (joined reasons), and
|
||||
``has_durable_source_pointer``. ``allow_incomplete`` is an explicit
|
||||
operator override that forces ``complete`` True.
|
||||
"""
|
||||
t = (title or "").strip()
|
||||
b = (body or "").strip()
|
||||
missing_fields: list[str] = []
|
||||
vague_fields: list[str] = []
|
||||
reasons: list[str] = []
|
||||
|
||||
if not t:
|
||||
missing_fields.append("title")
|
||||
reasons.append("issue title is required")
|
||||
else:
|
||||
phrase = find_vague_reference(t)
|
||||
if phrase:
|
||||
vague_fields.append("title")
|
||||
reasons.append(
|
||||
f"title is a vague reference ('{phrase}') with no durable "
|
||||
"content or source pointer"
|
||||
)
|
||||
|
||||
if b:
|
||||
phrase = find_vague_reference(b)
|
||||
if phrase:
|
||||
vague_fields.append("body")
|
||||
reasons.append(
|
||||
f"body is a vague reference ('{phrase}') with no durable "
|
||||
"content or source pointer"
|
||||
)
|
||||
|
||||
complete = allow_incomplete or (not missing_fields and not vague_fields)
|
||||
return {
|
||||
"complete": complete,
|
||||
"missing_fields": missing_fields,
|
||||
"vague_fields": vague_fields,
|
||||
"reasons": reasons,
|
||||
"diagnose": "; ".join(reasons),
|
||||
"has_durable_source_pointer": has_durable_source_pointer(b)
|
||||
or has_durable_source_pointer(t),
|
||||
"override_applied": bool(
|
||||
allow_incomplete and (missing_fields or vague_fields)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def pre_create_issue_content_gate(
|
||||
title: str,
|
||||
body: str = "",
|
||||
*,
|
||||
allow_incomplete: bool = False,
|
||||
) -> dict:
|
||||
"""Content gate wrapper mirroring the duplicate gate shape.
|
||||
|
||||
``performed`` is True when the content is durable enough to create (or an
|
||||
explicit override was supplied). When False, the caller must return
|
||||
BLOCKED + DIAGNOSE and must not create the issue.
|
||||
"""
|
||||
assessment = assess_issue_content(
|
||||
title, body, allow_incomplete=allow_incomplete
|
||||
)
|
||||
assessment["performed"] = assessment["complete"]
|
||||
return assessment
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
|
||||
|
||||
When an issue's own already-pushed branch exists, lock reacquisition must be
|
||||
allowed (adoption) instead of being treated as #400 duplicate competing work.
|
||||
This module isolates the pure decision so it can be unit-tested apart from the
|
||||
MCP server's live Gitea calls.
|
||||
|
||||
Adoption is granted only for the issue's *exact* requested branch. Any other
|
||||
branch that merely contains the same ``issue-<n>`` marker is competing work and
|
||||
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
|
||||
safety checks are enforced by the caller before this decision is consulted;
|
||||
this module additionally records whether they passed for proof purposes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
ADOPT = "adopt_existing_branch"
|
||||
BLOCK_COMPETING = "block_competing_branch"
|
||||
NO_MATCH = "no_matching_branch"
|
||||
|
||||
# Citable decision labels aligned with the ``assess_own_branch_adoption``
|
||||
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
|
||||
# recovery reports (#473-style) can quote the lock tool output directly
|
||||
# instead of inferring adoption from separate offline checks (#477).
|
||||
DECISION_LABELS = {
|
||||
ADOPT: "ADOPT",
|
||||
BLOCK_COMPETING: "BLOCK_COMPETING",
|
||||
NO_MATCH: "NO_MATCH",
|
||||
}
|
||||
|
||||
_SAFE_NEXT_ACTIONS = {
|
||||
ADOPT: (
|
||||
"Own existing branch adopted for lock recovery; proceed to "
|
||||
"gitea_create_pr for this issue and cite this adoption proof."
|
||||
),
|
||||
BLOCK_COMPETING: (
|
||||
"Competing same-issue branch(es) exist; resolve branch ownership "
|
||||
"before locking. No adoption performed (fail closed)."
|
||||
),
|
||||
NO_MATCH: (
|
||||
"No existing branch carries this issue marker; normal lock path "
|
||||
"applied. No adoption performed."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def decision_label(outcome: str) -> str:
|
||||
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
|
||||
return DECISION_LABELS.get(outcome, "UNKNOWN")
|
||||
|
||||
|
||||
def safe_next_action(outcome: str) -> str:
|
||||
"""Return the safe next action string for an adoption *outcome*."""
|
||||
return _SAFE_NEXT_ACTIONS.get(
|
||||
outcome, "Unknown adoption outcome; treat as fail closed."
|
||||
)
|
||||
|
||||
|
||||
def _branch_name(entry) -> str:
|
||||
if isinstance(entry, dict):
|
||||
return str(entry.get("name") or "")
|
||||
return str(entry or "")
|
||||
|
||||
|
||||
def _branch_sha(entry) -> str | None:
|
||||
if isinstance(entry, dict):
|
||||
sha = entry.get("commit_sha")
|
||||
if sha:
|
||||
return str(sha)
|
||||
return None
|
||||
|
||||
|
||||
def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
|
||||
"""Return True when *branch_name* references issue *issue_number* exactly.
|
||||
|
||||
Uses a numeric word-boundary so ``issue-42`` does not match inside
|
||||
``issue-420`` (AC6 / #440).
|
||||
"""
|
||||
name = (branch_name or "").strip()
|
||||
if not name:
|
||||
return False
|
||||
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
|
||||
return re.search(pattern, name) is not None
|
||||
|
||||
|
||||
def assess_own_branch_adoption(
|
||||
*,
|
||||
issue_number: int,
|
||||
requested_branch: str,
|
||||
existing_branches,
|
||||
) -> dict:
|
||||
"""Decide whether an existing matching branch is adoptable.
|
||||
|
||||
Args:
|
||||
issue_number: The tracking issue number being locked.
|
||||
requested_branch: The exact branch the caller wants to lock.
|
||||
existing_branches: Iterable of remote branch entries — either names or
|
||||
dicts with ``name`` and optional ``commit_sha``.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
|
||||
* ``adopt`` (bool), ``block`` (bool)
|
||||
* ``reason`` (str)
|
||||
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
|
||||
* ``competing_branches`` (list[str])
|
||||
|
||||
ADOPT: the issue's exact branch exists and no other same-issue branch does.
|
||||
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
|
||||
NO_MATCH: no branch carries the issue marker — normal lock path applies.
|
||||
"""
|
||||
requested = (requested_branch or "").strip()
|
||||
|
||||
matches: list[tuple[str, str | None]] = []
|
||||
for entry in existing_branches or []:
|
||||
name = _branch_name(entry).strip()
|
||||
if _branch_carries_issue_marker(name, issue_number):
|
||||
matches.append((name, _branch_sha(entry)))
|
||||
|
||||
competing = sorted({name for name, _ in matches if name != requested})
|
||||
exact = [(name, sha) for name, sha in matches if name == requested]
|
||||
|
||||
# Fail closed whenever any non-requested same-issue branch exists, even if
|
||||
# the requested branch is also present: ownership is then ambiguous.
|
||||
if competing:
|
||||
return {
|
||||
"outcome": BLOCK_COMPETING,
|
||||
"adopt": False,
|
||||
"block": True,
|
||||
"reason": (
|
||||
f"issue #{issue_number} already has matching branch(es) "
|
||||
f"{competing} that are not the requested branch "
|
||||
f"'{requested}' (fail closed)"
|
||||
),
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
if exact:
|
||||
name, sha = exact[0]
|
||||
return {
|
||||
"outcome": ADOPT,
|
||||
"adopt": True,
|
||||
"block": False,
|
||||
"reason": (
|
||||
f"existing branch '{name}' is the exact requested branch for "
|
||||
f"issue #{issue_number}; adopting it for lock recovery"
|
||||
),
|
||||
"matched_branch": name,
|
||||
"matched_head_sha": sha,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"outcome": NO_MATCH,
|
||||
"adopt": False,
|
||||
"block": False,
|
||||
"reason": f"no existing branch matches issue #{issue_number}",
|
||||
"matched_branch": None,
|
||||
"matched_head_sha": None,
|
||||
"competing_branches": [],
|
||||
}
|
||||
|
||||
|
||||
def _matcher_summary(issue_number: int, assessment: dict) -> str:
|
||||
"""Explain, citably, why the assessed branch did or did not qualify.
|
||||
|
||||
Names the numeric word-boundary rule so reports can show that
|
||||
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
matched = assessment.get("matched_branch")
|
||||
competing = assessment.get("competing_branches") or []
|
||||
if outcome == ADOPT and matched:
|
||||
return (
|
||||
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
|
||||
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
|
||||
f"matched inside 'issue-{int(issue_number)}0')"
|
||||
)
|
||||
if outcome == BLOCK_COMPETING:
|
||||
return (
|
||||
f"competing same-issue branch(es) {competing} carry the "
|
||||
f"issue-{int(issue_number)} marker but are not the requested "
|
||||
f"branch; ownership is ambiguous (fail closed)"
|
||||
)
|
||||
return (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker "
|
||||
f"under the numeric word-boundary rule"
|
||||
)
|
||||
|
||||
|
||||
def _competing_branch_check(assessment: dict) -> dict:
|
||||
"""Structured competing-branch verdict for the proof block."""
|
||||
competing = list(assessment.get("competing_branches") or [])
|
||||
return {
|
||||
"result": "blocked" if competing else "clear",
|
||||
"competing_branches": competing,
|
||||
}
|
||||
|
||||
|
||||
def build_adoption_proof(
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
assessment: dict,
|
||||
open_pr_checked: bool,
|
||||
competing_lock_checked: bool,
|
||||
lock_file_path: str,
|
||||
lock_file_status: str,
|
||||
) -> dict:
|
||||
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
|
||||
|
||||
Requirement #4: adoption results must carry issue number, branch name,
|
||||
branch head commit, adoption reason, no-existing-PR proof, no-competing-
|
||||
live-lock proof, and lock file path/status.
|
||||
|
||||
#477: additionally surface explicit, citable adoption-proof fields tied to
|
||||
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
|
||||
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
|
||||
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
|
||||
recovery session can quote the live lock response directly. The explicit
|
||||
fields are populated for any outcome; ``adopted_branch`` /
|
||||
``adopted_branch_head`` are set only when the outcome is ADOPT so a
|
||||
non-adoption proof can never be misread as claiming adoption.
|
||||
"""
|
||||
outcome = assessment.get("outcome")
|
||||
adopted = outcome == ADOPT
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"branch_head_commit": assessment.get("matched_head_sha"),
|
||||
"adoption_reason": assessment.get("reason"),
|
||||
"no_existing_pr_proof": bool(open_pr_checked),
|
||||
"no_competing_live_lock_proof": bool(competing_lock_checked),
|
||||
"lock_file_path": lock_file_path,
|
||||
"lock_file_status": lock_file_status,
|
||||
# Explicit citable fields (#477).
|
||||
"adoption_decision": decision_label(outcome),
|
||||
"adopted": adopted,
|
||||
"adopted_branch": branch_name if adopted else None,
|
||||
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
|
||||
"matcher_summary": _matcher_summary(issue_number, assessment),
|
||||
"competing_branch_check": _competing_branch_check(assessment),
|
||||
"safe_next_action": safe_next_action(outcome),
|
||||
}
|
||||
|
||||
|
||||
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
|
||||
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
|
||||
|
||||
Requirement #477 AC2: non-adoption lock responses must stay clear and must
|
||||
not imply adoption. This returns explicit ``adopted: False`` metadata with
|
||||
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
|
||||
without ever asserting a branch was adopted.
|
||||
"""
|
||||
return {
|
||||
"issue_number": issue_number,
|
||||
"branch_name": branch_name,
|
||||
"adoption_decision": DECISION_LABELS[NO_MATCH],
|
||||
"adopted": False,
|
||||
"adopted_branch": None,
|
||||
"adopted_branch_head": None,
|
||||
"matcher_summary": (
|
||||
f"no existing branch carries the issue-{int(issue_number)} marker; "
|
||||
f"normal lock path (no adoption)"
|
||||
),
|
||||
"competing_branch_check": {"result": "clear", "competing_branches": []},
|
||||
"safe_next_action": safe_next_action(NO_MATCH),
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Issue-lock provenance and external-state disclosure (#447).
|
||||
|
||||
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
|
||||
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
|
||||
blocked at PR creation unless explicit operator override proof is recorded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
|
||||
|
||||
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
|
||||
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
|
||||
SOURCE_OPERATOR_OVERRIDE = "operator_override"
|
||||
|
||||
SANCTIONED_LOCK_SOURCES = frozenset({
|
||||
SOURCE_LOCK_ISSUE,
|
||||
SOURCE_LOCK_ADOPTION,
|
||||
SOURCE_OPERATOR_OVERRIDE,
|
||||
})
|
||||
|
||||
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
|
||||
|
||||
_ISSUE_LOCK_PATH_RE = re.compile(
|
||||
r"(?:/tmp/)?gitea_issue_lock\.json",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCK_SEED_RE = re.compile(
|
||||
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
|
||||
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_REMOVE_RE = re.compile(
|
||||
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_LOCK_READ_RE = re.compile(
|
||||
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EXTERNAL_NONE_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXTERNAL_FIELD_RE = re.compile(
|
||||
r"external[- ]state mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_CLEANUP_ONLY_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_CREATED_RE = re.compile(
|
||||
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
|
||||
r"PR\s+creation\s+(?:succeeded|complete))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEW_APPROVE_RE = re.compile(
|
||||
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
|
||||
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OVERRIDE_PROOF_RE = re.compile(
|
||||
r"operator[- ]override\s+proof\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def build_sanctioned_lock_provenance(
|
||||
*,
|
||||
tool: str,
|
||||
source: str = SOURCE_LOCK_ISSUE,
|
||||
claimant: dict | None = None,
|
||||
adoption: dict | None = None,
|
||||
) -> dict:
|
||||
"""Return provenance metadata stored with a sanctioned lock write."""
|
||||
entry = {
|
||||
"source": source,
|
||||
"written_at": _utc_now_iso(),
|
||||
"written_by_tool": tool,
|
||||
"lock_file_path": ISSUE_LOCK_FILE,
|
||||
}
|
||||
if claimant:
|
||||
entry["claimant"] = claimant
|
||||
if adoption:
|
||||
entry["adoption"] = adoption
|
||||
return entry
|
||||
|
||||
|
||||
def operator_override_requested() -> bool:
|
||||
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
|
||||
text = (reason or "").strip()
|
||||
if not text:
|
||||
raise ValueError(
|
||||
"operator override requires a non-empty override reason (fail closed)"
|
||||
)
|
||||
entry = build_sanctioned_lock_provenance(
|
||||
tool="operator_override",
|
||||
source=SOURCE_OPERATOR_OVERRIDE,
|
||||
claimant=claimant,
|
||||
)
|
||||
entry["override_reason"] = text
|
||||
return entry
|
||||
|
||||
|
||||
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
|
||||
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
|
||||
data = lock_data if isinstance(lock_data, dict) else {}
|
||||
reasons: list[str] = []
|
||||
provenance = data.get("lock_provenance")
|
||||
if not isinstance(provenance, dict):
|
||||
reasons.append(
|
||||
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
|
||||
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
|
||||
)
|
||||
return _provenance_result(False, reasons, provenance)
|
||||
|
||||
source = str(provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_LOCK_SOURCES:
|
||||
reasons.append(
|
||||
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
|
||||
)
|
||||
|
||||
if source == SOURCE_OPERATOR_OVERRIDE and not str(
|
||||
provenance.get("override_reason") or ""
|
||||
).strip():
|
||||
reasons.append(
|
||||
"operator_override lock provenance requires override_reason proof"
|
||||
)
|
||||
|
||||
if not data.get("work_lease"):
|
||||
reasons.append("issue lock file missing work_lease metadata")
|
||||
|
||||
if not str(provenance.get("written_by_tool") or "").strip():
|
||||
reasons.append("issue lock provenance missing written_by_tool")
|
||||
|
||||
proven = not reasons
|
||||
return _provenance_result(proven, reasons, provenance)
|
||||
|
||||
|
||||
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"lock_provenance": provenance,
|
||||
}
|
||||
|
||||
|
||||
def format_lock_provenance_error(assessment: dict) -> str:
|
||||
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
|
||||
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
|
||||
|
||||
|
||||
def _lock_activity_detected(text: str) -> dict[str, bool]:
|
||||
body = text or ""
|
||||
return {
|
||||
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
|
||||
"remove": bool(_LOCK_REMOVE_RE.search(body)),
|
||||
"read": bool(_LOCK_READ_RE.search(body)),
|
||||
}
|
||||
|
||||
|
||||
def _external_state_discloses_lock(text: str) -> bool:
|
||||
match = _EXTERNAL_FIELD_RE.search(text or "")
|
||||
if not match:
|
||||
return False
|
||||
value = (match.group(1) or "").strip().lower()
|
||||
if value in {"", "none", "n/a"}:
|
||||
return False
|
||||
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
|
||||
|
||||
|
||||
def assess_issue_lock_external_state_report(report_text: str) -> dict:
|
||||
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
|
||||
text = report_text or ""
|
||||
activity = _lock_activity_detected(text)
|
||||
if not any(activity.values()):
|
||||
return {"proven": True, "block": False, "reasons": [], "activity": activity}
|
||||
|
||||
reasons: list[str] = []
|
||||
disclosed = _external_state_discloses_lock(text)
|
||||
|
||||
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions seeding/restoring gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif activity["seed_or_restore"] and not disclosed:
|
||||
reasons.append(
|
||||
"report mentions issue-lock file activity but External-state mutations "
|
||||
"does not disclose read/write of gitea_issue_lock.json"
|
||||
)
|
||||
|
||||
if activity["remove"]:
|
||||
if _EXTERNAL_NONE_RE.search(text):
|
||||
reasons.append(
|
||||
"report mentions removing gitea_issue_lock.json but claims "
|
||||
"External-state mutations: none"
|
||||
)
|
||||
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
|
||||
reasons.append(
|
||||
"report removes issue lock but classifies it as cleanup only; "
|
||||
"record under External-state mutations"
|
||||
)
|
||||
elif not disclosed:
|
||||
reasons.append(
|
||||
"report mentions deleting issue lock without External-state "
|
||||
"mutation disclosure"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"activity": activity,
|
||||
}
|
||||
|
||||
|
||||
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
|
||||
"""Block reports that created a PR via manual lock seed without override proof."""
|
||||
text = report_text or ""
|
||||
seeded = bool(_LOCK_SEED_RE.search(text))
|
||||
created = bool(_PR_CREATED_RE.search(text))
|
||||
if not (seeded and created):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
if _OVERRIDE_PROOF_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report created/opened a PR after manual issue-lock seeding without "
|
||||
"operator override proof"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
|
||||
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
|
||||
text = report_text or ""
|
||||
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report mixes author-side PR creation and reviewer approval in one "
|
||||
"final handoff; split author and reviewer sessions"
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
|
||||
|
||||
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
|
||||
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
|
||||
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
|
||||
via a per-process pointer file so concurrent repos/issues never clobber each
|
||||
other. Acquisition is serialized per issue with ``fcntl.flock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
|
||||
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
|
||||
WORK_LEASE_TTL_HOURS = 4
|
||||
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
|
||||
|
||||
class LockContentionError(RuntimeError):
|
||||
"""Raised when an exclusive per-issue lock cannot be acquired."""
|
||||
|
||||
|
||||
def default_lock_dir() -> str:
|
||||
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
|
||||
return raw or DEFAULT_LOCK_DIR
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def lock_key(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
) -> str:
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (remote, org, repo, str(issue_number))
|
||||
)
|
||||
|
||||
|
||||
def lock_file_path(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
|
||||
|
||||
|
||||
def session_pointer_path(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
return os.path.join(root, f"session-{os.getpid()}.json")
|
||||
|
||||
|
||||
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def flock_path(json_path: str) -> str:
|
||||
return f"{json_path}.lock"
|
||||
|
||||
|
||||
def is_process_alive(pid: int | None) -> bool:
|
||||
if not pid or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except OSError as exc:
|
||||
return exc.errno != errno.ESRCH
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise LockContentionError(
|
||||
f"could not acquire exclusive lock on '{lock_path}'"
|
||||
) from exc
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def read_lock_file(path: str) -> dict[str, Any] | None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path or not os.path.exists(lock_path):
|
||||
return None
|
||||
try:
|
||||
with open(lock_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
||||
lock_path = (path or "").strip()
|
||||
if not lock_path:
|
||||
raise ValueError("lock path is required (fail closed)")
|
||||
parent = os.path.dirname(lock_path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, lock_path)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
|
||||
"""Persist a keyed lock and bind it to the current process session."""
|
||||
remote = str(lock_data.get("remote") or "")
|
||||
org = str(lock_data.get("org") or "")
|
||||
repo = str(lock_data.get("repo") or "")
|
||||
issue_number = int(lock_data.get("issue_number") or 0)
|
||||
if not remote or not org or not repo or issue_number <= 0:
|
||||
raise ValueError("lock record must include remote, org, repo, and issue_number")
|
||||
|
||||
root = _ensure_lock_dir(lock_dir)
|
||||
path = lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=root,
|
||||
)
|
||||
record = dict(lock_data)
|
||||
record["lock_file_path"] = path
|
||||
record["session_pid"] = os.getpid()
|
||||
record.setdefault("pid", os.getpid())
|
||||
|
||||
pointer = {
|
||||
"pid": os.getpid(),
|
||||
"lock_file_path": path,
|
||||
"issue_number": issue_number,
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
sentinel = flock_path(path)
|
||||
try:
|
||||
with _exclusive_file_lock(sentinel):
|
||||
existing = read_lock_file(path)
|
||||
overwrite_block = assess_foreign_lock_overwrite(existing, record)
|
||||
if overwrite_block:
|
||||
raise RuntimeError(overwrite_block)
|
||||
lease_block = assess_same_issue_lease_conflict(
|
||||
existing,
|
||||
issue_number=issue_number,
|
||||
branch_name=str(record.get("branch_name") or ""),
|
||||
worktree_path=str(record.get("worktree_path") or ""),
|
||||
)
|
||||
if lease_block:
|
||||
raise RuntimeError(lease_block)
|
||||
save_lock_file(path, record)
|
||||
save_lock_file(session_pointer_path(root), pointer)
|
||||
except LockContentionError as exc:
|
||||
competing = read_lock_file(path)
|
||||
if competing:
|
||||
owner_pid = competing.get("session_pid") or competing.get("pid")
|
||||
raise RuntimeError(
|
||||
f"Issue #{issue_number} lock contention: {exc}; competing owner "
|
||||
f"pid={owner_pid} (fail closed)"
|
||||
) from exc
|
||||
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
|
||||
return path
|
||||
|
||||
|
||||
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
pointer = read_lock_file(session_pointer_path(root))
|
||||
if not pointer:
|
||||
return None
|
||||
lock_path = str(pointer.get("lock_file_path") or "").strip()
|
||||
if not lock_path:
|
||||
return None
|
||||
return read_lock_file(lock_path)
|
||||
|
||||
|
||||
def load_issue_lock(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
return read_lock_file(
|
||||
lock_file_path(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
lock_dir=lock_dir,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
||||
root = (lock_dir or default_lock_dir()).strip()
|
||||
if not os.path.isdir(root):
|
||||
return []
|
||||
paths: list[str] = []
|
||||
for name in os.listdir(root):
|
||||
if not name.endswith(".json") or name.startswith("session-"):
|
||||
continue
|
||||
paths.append(os.path.join(root, name))
|
||||
return sorted(paths)
|
||||
|
||||
|
||||
def find_lock_for_branch(
|
||||
*,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if (
|
||||
str(lock.get("remote") or "") == remote
|
||||
and str(lock.get("org") or "") == org
|
||||
and str(lock.get("repo") or "") == repo
|
||||
and str(lock.get("branch_name") or "").strip() == target
|
||||
):
|
||||
lock = dict(lock)
|
||||
lock.setdefault("lock_file_path", path)
|
||||
return lock
|
||||
return None
|
||||
|
||||
|
||||
def _lease_now(now: datetime | None = None) -> datetime:
|
||||
return now or datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_lease_timestamp(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
|
||||
if not lock:
|
||||
return None
|
||||
lease = lock.get("work_lease")
|
||||
if not isinstance(lease, dict):
|
||||
return None
|
||||
return _parse_lease_timestamp(lease.get("expires_at"))
|
||||
|
||||
|
||||
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
expires = lease_expires_at(lock)
|
||||
if expires is None:
|
||||
return False
|
||||
return expires <= _lease_now(now)
|
||||
|
||||
|
||||
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
||||
return assess_lock_freshness(lock, now=now)["live"]
|
||||
|
||||
|
||||
def assess_lock_freshness(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a lock as live, expired, stale, or absent."""
|
||||
current = _lease_now(now)
|
||||
if not lock_data:
|
||||
return {
|
||||
"status": "absent",
|
||||
"live": False,
|
||||
"stale": False,
|
||||
"reason": "no lock record",
|
||||
}
|
||||
|
||||
expires_at = lease_expires_at(lock_data)
|
||||
lease = lock_data.get("work_lease")
|
||||
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
|
||||
if heartbeat_at is None and isinstance(lease, dict):
|
||||
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
|
||||
|
||||
pid = lock_data.get("session_pid")
|
||||
if pid is None:
|
||||
pid = lock_data.get("pid")
|
||||
pid_alive = is_process_alive(pid) if pid is not None else False
|
||||
|
||||
if expires_at and expires_at <= current:
|
||||
return {
|
||||
"status": "expired",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"lease expired at {expires_at.isoformat()}",
|
||||
"pid_alive": pid_alive,
|
||||
}
|
||||
|
||||
if pid is not None and not pid_alive:
|
||||
return {
|
||||
"status": "stale",
|
||||
"live": False,
|
||||
"stale": True,
|
||||
"reason": f"owner pid {pid} is not alive",
|
||||
"pid_alive": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "live",
|
||||
"live": True,
|
||||
"stale": False,
|
||||
"reason": "lock heartbeat and lease are fresh",
|
||||
"pid_alive": pid_alive,
|
||||
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
try:
|
||||
return os.path.realpath(left) == os.path.realpath(right)
|
||||
except OSError:
|
||||
return left == right
|
||||
|
||||
|
||||
|
||||
def assess_expired_lock_reclaim(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether an expired/stale author issue lock may be reclaimed (#601).
|
||||
|
||||
Required proof (any reclaim of non-live lock):
|
||||
* lease not live (expired or dead pid)
|
||||
* owner process dead OR worktree missing
|
||||
* no force-delete of live foreign ownership
|
||||
"""
|
||||
if not existing_lock:
|
||||
return {
|
||||
"reclaim_allowed": True,
|
||||
"reasons": ["no existing lock"],
|
||||
"freshness": assess_lock_freshness(None, now=now),
|
||||
}
|
||||
freshness = assess_lock_freshness(existing_lock, now=now)
|
||||
if freshness.get("live"):
|
||||
return {
|
||||
"reclaim_allowed": False,
|
||||
"reasons": ["lock is still live; cannot reclaim (fail closed)"],
|
||||
"freshness": freshness,
|
||||
}
|
||||
pid = existing_lock.get("session_pid")
|
||||
if pid is None:
|
||||
pid = existing_lock.get("pid")
|
||||
dead = not is_process_alive(pid) if pid is not None else True
|
||||
wt = str(existing_lock.get("worktree_path") or "")
|
||||
missing_wt = (not wt) or (not os.path.isdir(os.path.realpath(wt)))
|
||||
if not (dead or missing_wt):
|
||||
return {
|
||||
"reclaim_allowed": False,
|
||||
"reasons": [
|
||||
"expired/stale lock still has live owner pid and present worktree; "
|
||||
"recovery review required (fail closed)"
|
||||
],
|
||||
"freshness": freshness,
|
||||
"owner_pid_dead": dead,
|
||||
"worktree_missing": missing_wt,
|
||||
}
|
||||
return {
|
||||
"reclaim_allowed": True,
|
||||
"reasons": [
|
||||
"non-live lock with dead process and/or missing worktree; "
|
||||
"sanctioned reclaim allowed"
|
||||
],
|
||||
"freshness": freshness,
|
||||
"owner_pid_dead": dead,
|
||||
"worktree_missing": missing_wt,
|
||||
"prior_branch": existing_lock.get("branch_name"),
|
||||
"prior_worktree": existing_lock.get("worktree_path"),
|
||||
"prior_pid": pid,
|
||||
}
|
||||
|
||||
|
||||
def assess_same_issue_lease_conflict(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Return a fail-closed error when a competing live lease blocks acquisition."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
existing_issue = existing_lock.get("issue_number")
|
||||
lease = existing_lock.get("work_lease")
|
||||
existing_operation = (
|
||||
lease.get("operation_type")
|
||||
if isinstance(lease, dict)
|
||||
else AUTHOR_ISSUE_WORK_LEASE
|
||||
)
|
||||
if existing_issue != issue_number or existing_operation != operation_type:
|
||||
return None
|
||||
|
||||
existing_branch = existing_lock.get("branch_name")
|
||||
existing_worktree = existing_lock.get("worktree_path")
|
||||
same_owner = (
|
||||
existing_branch == branch_name
|
||||
and _same_realpath(str(existing_worktree or ""), worktree_path)
|
||||
)
|
||||
if is_lease_expired(existing_lock, now=now):
|
||||
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
|
||||
if reclaim.get("reclaim_allowed"):
|
||||
# #601: expired + dead pid / missing worktree may be reclaimed
|
||||
# through the normal lock path (sanctioned overwrite).
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} has an expired {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
|
||||
"Recovery review is required before takeover (fail closed)"
|
||||
)
|
||||
if same_owner:
|
||||
return None
|
||||
return (
|
||||
f"Issue #{issue_number} already has an active {operation_type} lease on "
|
||||
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def assess_foreign_lock_overwrite(
|
||||
existing_lock: dict[str, Any] | None,
|
||||
incoming_lock: dict[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> str | None:
|
||||
"""Block writes that would clobber an unrelated live lease on the same key."""
|
||||
if not existing_lock:
|
||||
return None
|
||||
|
||||
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
|
||||
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
|
||||
same_worktree = _same_realpath(
|
||||
str(existing_lock.get("worktree_path") or ""),
|
||||
str(incoming_lock.get("worktree_path") or ""),
|
||||
)
|
||||
if same_issue and same_branch and same_worktree:
|
||||
return None
|
||||
if not is_lease_live(existing_lock, now=now):
|
||||
return None
|
||||
return (
|
||||
"Refusing to overwrite a live foreign issue lock "
|
||||
f"(issue #{existing_lock.get('issue_number')}, "
|
||||
f"branch '{existing_lock.get('branch_name')}', "
|
||||
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
|
||||
)
|
||||
|
||||
|
||||
def find_live_lock_for_branch(
|
||||
branch_name: str,
|
||||
lock_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
target = (branch_name or "").strip()
|
||||
if not target:
|
||||
return None
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if not is_lease_live(lock):
|
||||
continue
|
||||
record = dict(lock)
|
||||
record.setdefault("lock_file_path", path)
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def resolve_locked_branch_for_session(
|
||||
branch_name: str | None = None,
|
||||
lock_dir: str | None = None,
|
||||
) -> str:
|
||||
if branch_name:
|
||||
lock = find_live_lock_for_branch(branch_name, lock_dir)
|
||||
if lock:
|
||||
return str(lock.get("branch_name") or "")
|
||||
lock = read_session_issue_lock(lock_dir)
|
||||
return str((lock or {}).get("branch_name") or "")
|
||||
|
||||
|
||||
def has_active_issue_lock(
|
||||
branch: str,
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
) -> bool:
|
||||
target = (branch or "").strip()
|
||||
if not target:
|
||||
return False
|
||||
for path in iter_lock_files(lock_dir):
|
||||
lock = read_lock_file(path)
|
||||
if not lock:
|
||||
continue
|
||||
if str(lock.get("branch_name") or "").strip() != target:
|
||||
continue
|
||||
if is_lease_live(lock):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def verify_lock_for_mutation(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
issue_number: int | None = None,
|
||||
branch_name: str | None = None,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-check lock ownership immediately before a mutation (#438)."""
|
||||
reasons: list[str] = []
|
||||
if not lock_data:
|
||||
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
|
||||
|
||||
freshness = assess_lock_freshness(lock_data)
|
||||
if not freshness["live"]:
|
||||
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
|
||||
|
||||
if issue_number is not None and lock_data.get("issue_number") != issue_number:
|
||||
reasons.append(
|
||||
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
|
||||
)
|
||||
|
||||
if branch_name is not None and lock_data.get("branch_name") != branch_name:
|
||||
reasons.append(
|
||||
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
|
||||
f"'{branch_name}' (fail closed)"
|
||||
)
|
||||
|
||||
if worktree_path is not None:
|
||||
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
|
||||
declared = os.path.realpath(worktree_path)
|
||||
if locked != declared:
|
||||
reasons.append(
|
||||
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"freshness": freshness,
|
||||
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
|
||||
}
|
||||
|
||||
|
||||
def list_live_locks(
|
||||
*,
|
||||
lock_dir: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return live per-issue locks for queue visibility."""
|
||||
live: list[dict[str, Any]] = []
|
||||
for path in iter_lock_files(lock_dir):
|
||||
record = read_lock_file(path)
|
||||
if not record:
|
||||
continue
|
||||
freshness = assess_lock_freshness(record, now=now)
|
||||
if not freshness["live"]:
|
||||
continue
|
||||
live.append(
|
||||
{
|
||||
"issue_number": record.get("issue_number"),
|
||||
"branch_name": record.get("branch_name"),
|
||||
"remote": record.get("remote"),
|
||||
"org": record.get("org"),
|
||||
"repo": record.get("repo"),
|
||||
"worktree_path": record.get("worktree_path"),
|
||||
"pid": record.get("session_pid") or record.get("pid"),
|
||||
"claimant": (
|
||||
record.get("claimant")
|
||||
or (record.get("work_lease") or {}).get("claimant")
|
||||
),
|
||||
"freshness": freshness,
|
||||
"lock_path": record.get("lock_file_path") or path,
|
||||
}
|
||||
)
|
||||
return live
|
||||
|
||||
|
||||
def format_lock_proof(
|
||||
lock_data: dict[str, Any] | None,
|
||||
*,
|
||||
freshness: dict[str, Any] | None = None,
|
||||
competing_live_locks: list[dict[str, Any]] | None = None,
|
||||
released: bool | None = None,
|
||||
) -> str:
|
||||
"""Canonical issue-lock proof string for final reports."""
|
||||
if not lock_data:
|
||||
return "issue lock proof: not acquired"
|
||||
fresh = freshness or assess_lock_freshness(lock_data)
|
||||
owner = lock_data.get("claimant") or {}
|
||||
if not owner and isinstance(lock_data.get("work_lease"), dict):
|
||||
owner = lock_data["work_lease"].get("claimant") or {}
|
||||
parts = [
|
||||
"issue lock proof:",
|
||||
f"acquired issue #{lock_data.get('issue_number')}",
|
||||
f"branch {lock_data.get('branch_name')}",
|
||||
f"owner {owner.get('profile') or 'unknown'}",
|
||||
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
|
||||
f"freshness {fresh.get('status')}",
|
||||
]
|
||||
if competing_live_locks is not None:
|
||||
parts.append(
|
||||
"no competing live lock"
|
||||
if not competing_live_locks
|
||||
else f"competing live locks {len(competing_live_locks)}"
|
||||
)
|
||||
if released is True:
|
||||
parts.append("lock released")
|
||||
elif released is False:
|
||||
parts.append("lock retained")
|
||||
return "; ".join(parts)
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Issue-lock worktree validation (#249).
|
||||
|
||||
Author issue locks must validate the caller's own scratch clone (or declared
|
||||
worktree path), not the shared MCP server working directory. A clean scratch at
|
||||
``master``/``main`` must remain lockable while an unrelated session leaves the
|
||||
shared dev worktree dirty or on a feature branch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
|
||||
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
|
||||
BASE_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
|
||||
|
||||
def resolve_author_worktree_path(
|
||||
explicit: str | None,
|
||||
project_root: str,
|
||||
) -> str:
|
||||
"""Resolve the author worktree path for lock/PR gates."""
|
||||
path = (explicit or "").strip()
|
||||
if not path:
|
||||
path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip()
|
||||
if not path:
|
||||
path = project_root
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> dict:
|
||||
"""Read branch name and porcelain status from a git worktree.
|
||||
|
||||
``extra_bases`` names additional branches (e.g. an approved stacked base)
|
||||
that may anchor base-equivalence in addition to master/main/dev. When empty
|
||||
(the default), only the normal base branches are considered.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
return {"current_branch": None, "porcelain_status": ""}
|
||||
|
||||
branch_res = subprocess.run(
|
||||
["git", "-C", path, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
current_branch = (branch_res.stdout or "").strip() or None
|
||||
|
||||
status_res = subprocess.run(
|
||||
["git", "-C", path, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
root_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
|
||||
return {
|
||||
"current_branch": current_branch,
|
||||
"porcelain_status": status_res.stdout or "",
|
||||
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
|
||||
"head_sha": head_sha,
|
||||
"base_branch": base_branch,
|
||||
"base_sha": base_sha,
|
||||
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
|
||||
}
|
||||
|
||||
|
||||
def assess_issue_lock_worktree(
|
||||
*,
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
porcelain_status: str,
|
||||
base_equivalent: bool | None = None,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when lock preconditions are not met on the declared worktree."""
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
reasons: list[str] = []
|
||||
path = (worktree_path or "").strip()
|
||||
if not path:
|
||||
reasons.append("worktree path not declared for issue lock; fail closed")
|
||||
return _assessment(False, reasons, path, None, [])
|
||||
|
||||
branch = (current_branch or "").strip()
|
||||
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
||||
|
||||
if dirty_files:
|
||||
reasons.append(
|
||||
"tracked file edits exist before issue lock; "
|
||||
f"lock must precede implementation work in '{path}' "
|
||||
f"(dirty files: {', '.join(dirty_files)})"
|
||||
)
|
||||
|
||||
if base_equivalent is False:
|
||||
reasons.append(
|
||||
"issue lock worktree must be base-equivalent to one of "
|
||||
f"{_base_list(bases)} before implementation work; inspected "
|
||||
f"branch '{branch or '(detached)'}' at '{path}'"
|
||||
)
|
||||
elif base_equivalent is None:
|
||||
if not branch:
|
||||
reasons.append(
|
||||
"current branch unknown (detached HEAD?); issue lock base-equivalence "
|
||||
f"to {_base_list(bases)} could not be proven"
|
||||
)
|
||||
elif branch not in bases:
|
||||
reasons.append(
|
||||
"issue lock worktree base-equivalence could not be proven; "
|
||||
f"branch '{branch}' is not {_base_list(bases)}"
|
||||
)
|
||||
|
||||
proven = not reasons
|
||||
return _assessment(
|
||||
proven,
|
||||
reasons,
|
||||
path,
|
||||
branch or None,
|
||||
dirty_files,
|
||||
inspected_git_root=inspected_git_root,
|
||||
base_branch=base_branch,
|
||||
base_equivalent=base_equivalent,
|
||||
)
|
||||
|
||||
|
||||
def format_issue_lock_worktree_error(assessment: dict) -> str:
|
||||
"""Format a single fail-closed error for ``gitea_lock_issue``."""
|
||||
reasons = list(assessment.get("reasons") or [])
|
||||
if not reasons:
|
||||
reasons = ["issue lock worktree validation failed"]
|
||||
return "; ".join(reasons) + " (fail closed)"
|
||||
|
||||
|
||||
def verify_pr_worktree_matches_lock(
|
||||
locked_worktree_path: str | None,
|
||||
declared_worktree_path: str | None,
|
||||
project_root: str,
|
||||
) -> dict:
|
||||
"""PR creation must use the same worktree the lock was validated against."""
|
||||
locked = (locked_worktree_path or "").strip()
|
||||
if not locked:
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
|
||||
declared = resolve_author_worktree_path(declared_worktree_path, project_root)
|
||||
locked_real = os.path.realpath(locked)
|
||||
declared_real = os.path.realpath(declared)
|
||||
if locked_real != declared_real:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
f"PR worktree '{declared_real}' does not match locked worktree "
|
||||
f"'{locked_real}' (fail closed)"
|
||||
],
|
||||
"locked_worktree_path": locked_real,
|
||||
"declared_worktree_path": declared_real,
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"locked_worktree_path": locked_real,
|
||||
"declared_worktree_path": declared_real,
|
||||
}
|
||||
|
||||
|
||||
def _base_list(bases: frozenset[str]) -> str:
|
||||
return "/".join(sorted(bases))
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
worktree_path: str,
|
||||
current_branch: str | None,
|
||||
dirty_files: list[str],
|
||||
*,
|
||||
inspected_git_root: str | None = None,
|
||||
base_branch: str | None = None,
|
||||
base_equivalent: bool | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"worktree_path": worktree_path or None,
|
||||
"inspected_git_root": inspected_git_root,
|
||||
"current_branch": current_branch,
|
||||
"dirty_files": dirty_files,
|
||||
"base_branch": base_branch,
|
||||
"base_equivalent": base_equivalent,
|
||||
}
|
||||
|
||||
|
||||
def _find_matching_base_ref(
|
||||
path: str,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Return the stable branch ref whose commit matches HEAD, if any.
|
||||
|
||||
Normal base branches (master/main/dev) are always considered. ``extra_bases``
|
||||
adds explicitly-approved stacked bases; each is checked as a local ref and via
|
||||
the ``prgs``/``origin`` remotes.
|
||||
"""
|
||||
if not head_sha:
|
||||
return None, None
|
||||
candidates: list[str] = []
|
||||
for branch in sorted(BASE_BRANCHES):
|
||||
candidates.extend((f"origin/{branch}", branch))
|
||||
for branch in extra_bases:
|
||||
name = (branch or "").strip()
|
||||
if name:
|
||||
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
|
||||
for ref in candidates:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
sha = (res.stdout or "").strip()
|
||||
if res.returncode == 0 and sha == head_sha:
|
||||
return ref, sha
|
||||
return None, None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Early duplicate-work detection for author work-issue sessions (#400)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import issue_claim_heartbeat as claim_hb
|
||||
|
||||
PHASE_LOCK = "lock_issue"
|
||||
PHASE_COMMIT = "commit"
|
||||
PHASE_PUSH = "push"
|
||||
PHASE_CREATE_PR = "create_pr"
|
||||
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
|
||||
|
||||
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
|
||||
|
||||
|
||||
def _issue_pattern(issue_number: int) -> str:
|
||||
return f"issue-{int(issue_number)}"
|
||||
|
||||
|
||||
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
|
||||
return claim_hb._linked_open_pr(issue_number, open_prs)
|
||||
|
||||
|
||||
def _matching_branches(
|
||||
issue_number: int,
|
||||
branch_names: list[str],
|
||||
*,
|
||||
locked_branch: str | None = None,
|
||||
) -> list[str]:
|
||||
pattern = _issue_pattern(issue_number)
|
||||
matches = [
|
||||
name for name in (branch_names or [])
|
||||
if pattern in (name or "").lower()
|
||||
]
|
||||
if locked_branch:
|
||||
locked = locked_branch.strip()
|
||||
matches = [name for name in matches if name != locked]
|
||||
return matches
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
open_prs: list[dict] | None = None,
|
||||
branch_names: list[str] | None = None,
|
||||
claim_entry: dict | None = None,
|
||||
locked_branch: str | None = None,
|
||||
phase: str = PHASE_LOCK,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when duplicate work is already in flight for an issue."""
|
||||
reasons: list[str] = []
|
||||
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
|
||||
prs = list(open_prs or [])
|
||||
branches = list(branch_names or [])
|
||||
pattern = _issue_pattern(issue_number)
|
||||
|
||||
linked = _linked_open_pr(issue_number, prs)
|
||||
if linked:
|
||||
reasons.append(
|
||||
f"open PR #{linked.get('number')} already covers issue "
|
||||
f"#{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
conflicting_branches = _matching_branches(
|
||||
issue_number, branches, locked_branch=locked_branch
|
||||
)
|
||||
if conflicting_branches:
|
||||
names = ", ".join(conflicting_branches[:5])
|
||||
reasons.append(
|
||||
f"remote branch(es) already match issue pattern '{pattern}': "
|
||||
f"{names} (fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
entry = claim_entry or {}
|
||||
if entry.get("linked_open_pr") and not linked:
|
||||
reasons.append(
|
||||
f"claim inventory reports open PR #{entry['linked_open_pr']} "
|
||||
f"for issue #{issue_number} (fail closed)"
|
||||
)
|
||||
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
|
||||
|
||||
status = (entry.get("status") or "").strip().lower()
|
||||
if status in _ACTIVE_CLAIM_STATUSES and not linked:
|
||||
heartbeat = entry.get("latest_heartbeat") or {}
|
||||
claim_branch = (heartbeat.get("branch") or "").strip()
|
||||
if locked_branch and claim_branch and claim_branch != locked_branch:
|
||||
reasons.append(
|
||||
f"active claim lease on branch '{claim_branch}' blocks "
|
||||
f"work on '{locked_branch}' for issue #{issue_number} "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
elif not locked_branch and status == "active":
|
||||
reasons.append(
|
||||
f"issue #{issue_number} has an active claim lease "
|
||||
"(fail closed)"
|
||||
)
|
||||
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
|
||||
|
||||
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
|
||||
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
|
||||
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"performed": not block,
|
||||
"issue_number": issue_number,
|
||||
"phase": phase,
|
||||
"outcome": outcome,
|
||||
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
|
||||
"conflicting_branches": conflicting_branches,
|
||||
"claim_status": status or None,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"stop before mutating; preserve local work and produce a "
|
||||
"reconciliation handoff if a concurrent PR appeared after push"
|
||||
if block and phase == PHASE_CREATE_PR
|
||||
else "stop before mutating; do not commit or push duplicate work"
|
||||
if block
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
|
||||
"""Require explicit duplicate-work outcome wording in work-issue reports."""
|
||||
text = (report_text or "").lower()
|
||||
markers = {
|
||||
OUTCOME_DUPLICATE_PR_PREVENTED: (
|
||||
"duplicate pr prevented",
|
||||
"duplicate_pr_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
|
||||
"duplicate branch prevented",
|
||||
"duplicate_branch_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
|
||||
"duplicate commit prevented",
|
||||
"duplicate_commit_prevented",
|
||||
),
|
||||
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
|
||||
"duplicate work not prevented",
|
||||
"duplicate_work_not_prevented",
|
||||
"no duplicate work",
|
||||
),
|
||||
}
|
||||
matched = [
|
||||
key for key, phrases in markers.items()
|
||||
if any(phrase in text for phrase in phrases)
|
||||
]
|
||||
if len(matched) != 1:
|
||||
return {
|
||||
"complete": False,
|
||||
"downgraded": True,
|
||||
"reasons": [
|
||||
"work-issue report must state exactly one duplicate-work "
|
||||
"outcome (duplicate PR/branch/commit prevented, or "
|
||||
"duplicate work not prevented)"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"complete": True,
|
||||
"downgraded": False,
|
||||
"outcome": matched[0],
|
||||
"reasons": [],
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Canonical issue type/status label taxonomy and transition helpers (#513)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LabelSpec:
|
||||
name: str
|
||||
color: str
|
||||
description: str
|
||||
|
||||
|
||||
TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("type:bug", "b60205", "Bug or defect"),
|
||||
LabelSpec("type:feature", "a2eeef", "Feature or enhancement"),
|
||||
LabelSpec("type:process", "5319e7", "Process or policy work"),
|
||||
LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"),
|
||||
LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"),
|
||||
LabelSpec("type:docs", "006b75", "Documentation work"),
|
||||
LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"),
|
||||
LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"),
|
||||
LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"),
|
||||
LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"),
|
||||
)
|
||||
|
||||
STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("status:triage", "fbca04", "Issue needs triage"),
|
||||
LabelSpec("status:ready", "0e8a16", "Issue is ready for work"),
|
||||
LabelSpec("status:claimed", "fefe2e", "Issue is claimed"),
|
||||
LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"),
|
||||
LabelSpec("status:blocked", "b60205", "Issue is blocked"),
|
||||
LabelSpec("status:needs-review", "0052cc", "Issue work needs review"),
|
||||
LabelSpec("status:pr-open", "1d76db", "A linked PR is open"),
|
||||
LabelSpec(
|
||||
"status:changes-requested",
|
||||
"e11d21",
|
||||
"Reviewer requested changes on the linked PR",
|
||||
),
|
||||
LabelSpec("status:approved", "0e8a16", "Linked PR is approved"),
|
||||
LabelSpec("status:merged", "5319e7", "Linked PR is merged"),
|
||||
LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"),
|
||||
LabelSpec("status:done", "0e8a16", "Issue workflow is complete"),
|
||||
LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"),
|
||||
LabelSpec("status:wontfix", "000000", "Issue will not be fixed"),
|
||||
)
|
||||
|
||||
# Durable validation-outcome labels (#529): the four canonical distinctions a
|
||||
# reviewer report can carry. These are orthogonal to the single active status:*
|
||||
# label, so they use their own prefix and are not subject to the one-status
|
||||
# invariant.
|
||||
VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("validation:clean-pass", "0e8a16", "Validation was a clean pass"),
|
||||
LabelSpec(
|
||||
"validation:baseline-accepted",
|
||||
"fbca04",
|
||||
"Validation passed with a baseline-proven unrelated failure",
|
||||
),
|
||||
LabelSpec(
|
||||
"validation:blocked",
|
||||
"b60205",
|
||||
"Validation blocked by an unresolved failure",
|
||||
),
|
||||
LabelSpec(
|
||||
"validation:post-merge-moot",
|
||||
"5319e7",
|
||||
"Validation is post-merge moot (PR already merged/closed before review)",
|
||||
),
|
||||
)
|
||||
|
||||
# Lifecycle role-ownership labels (#603): which workflow role currently owns the
|
||||
# item. Advisory visibility only — the control-plane lease (#601) remains the
|
||||
# source of truth for mutation authority. Only one role:* label is active at a
|
||||
# time, mirroring the single-active-status invariant.
|
||||
ROLE_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("role:author", "1d76db", "Author currently owns the item"),
|
||||
LabelSpec("role:reviewer", "5319e7", "Reviewer currently owns the item"),
|
||||
LabelSpec("role:merger", "0e8a16", "Merger currently owns the item"),
|
||||
)
|
||||
|
||||
# Lifecycle hazard labels (#603): orthogonal warning flags surfacing dangerous
|
||||
# coordination conditions. Unlike status/role, multiple hazard:* labels may be
|
||||
# active at once, and they never substitute for live lease / PR state checks.
|
||||
HAZARD_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
LabelSpec("hazard:stale-lease", "d93f0b", "A stale or expired lease references this item"),
|
||||
LabelSpec(
|
||||
"hazard:workflow-contaminated",
|
||||
"b60205",
|
||||
"Session/workflow state is contaminated and must not mutate",
|
||||
),
|
||||
LabelSpec("hazard:conflicted", "e11d21", "Linked PR has merge conflicts"),
|
||||
LabelSpec("hazard:root-mutation", "b60205", "Work was mutated in the project root checkout"),
|
||||
LabelSpec("hazard:manual-state", "d93f0b", "Session or lease state was edited manually"),
|
||||
LabelSpec(
|
||||
"hazard:terminal-blocker",
|
||||
"000000",
|
||||
"A terminal review/merge lock blocks progress (#332/#602)",
|
||||
),
|
||||
)
|
||||
|
||||
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
||||
TYPE_LABEL_SPECS
|
||||
+ STATUS_LABEL_SPECS
|
||||
+ VALIDATION_LABEL_SPECS
|
||||
+ ROLE_LABEL_SPECS
|
||||
+ HAZARD_LABEL_SPECS
|
||||
)
|
||||
|
||||
TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS)
|
||||
STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS)
|
||||
VALIDATION_LABELS: frozenset[str] = frozenset(
|
||||
spec.name for spec in VALIDATION_LABEL_SPECS
|
||||
)
|
||||
ROLE_LABELS: frozenset[str] = frozenset(spec.name for spec in ROLE_LABEL_SPECS)
|
||||
HAZARD_LABELS: frozenset[str] = frozenset(spec.name for spec in HAZARD_LABEL_SPECS)
|
||||
CANONICAL_LABELS: frozenset[str] = frozenset(
|
||||
spec.name for spec in CANONICAL_LABEL_SPECS
|
||||
)
|
||||
|
||||
STATUS_TRANSITIONS: dict[str, str] = {
|
||||
"triage": "status:triage",
|
||||
"ready": "status:ready",
|
||||
"claim": "status:claimed",
|
||||
"claimed": "status:claimed",
|
||||
"start": "status:in-progress",
|
||||
"start_work": "status:in-progress",
|
||||
"in_progress": "status:in-progress",
|
||||
"in-progress": "status:in-progress",
|
||||
"block": "status:blocked",
|
||||
"blocked": "status:blocked",
|
||||
"needs_review": "status:needs-review",
|
||||
"needs-review": "status:needs-review",
|
||||
"reviewing": "status:needs-review",
|
||||
"pr_open": "status:pr-open",
|
||||
"pr-open": "status:pr-open",
|
||||
"changes_requested": "status:changes-requested",
|
||||
"changes-requested": "status:changes-requested",
|
||||
"changes": "status:changes-requested",
|
||||
"approved": "status:approved",
|
||||
"merge_ready": "status:approved",
|
||||
"merge-ready": "status:approved",
|
||||
"merge": "status:reconcile",
|
||||
"merged": "status:reconcile",
|
||||
"reconcile": "status:reconcile",
|
||||
"done": "status:done",
|
||||
"complete": "status:done",
|
||||
"duplicate": "status:duplicate",
|
||||
"wontfix": "status:wontfix",
|
||||
"abandoned": "status:wontfix",
|
||||
# #603 requested state:* synonyms folded into the canonical status vocabulary
|
||||
"needs_triage": "status:triage",
|
||||
"needs-triage": "status:triage",
|
||||
"authoring": "status:in-progress",
|
||||
}
|
||||
|
||||
# #603: single-active role ownership. Maps role kinds / role:* labels to the
|
||||
# canonical role label. Mirrors STATUS_TRANSITIONS for the role dimension.
|
||||
ROLE_TRANSITIONS: dict[str, str] = {
|
||||
"author": "role:author",
|
||||
"reviewer": "role:reviewer",
|
||||
"merger": "role:merger",
|
||||
}
|
||||
|
||||
# #603: hazard flag synonyms. Hazards are additive (not single-active), so this
|
||||
# only normalizes names; it does not drive replacement.
|
||||
HAZARD_TRANSITIONS: dict[str, str] = {
|
||||
"stale_lease": "hazard:stale-lease",
|
||||
"stale-lease": "hazard:stale-lease",
|
||||
"workflow_contaminated": "hazard:workflow-contaminated",
|
||||
"workflow-contaminated": "hazard:workflow-contaminated",
|
||||
"contaminated": "hazard:workflow-contaminated",
|
||||
"conflicted": "hazard:conflicted",
|
||||
"conflict": "hazard:conflicted",
|
||||
"root_mutation": "hazard:root-mutation",
|
||||
"root-mutation": "hazard:root-mutation",
|
||||
"manual_state": "hazard:manual-state",
|
||||
"manual-state": "hazard:manual-state",
|
||||
"terminal_blocker": "hazard:terminal-blocker",
|
||||
"terminal-blocker": "hazard:terminal-blocker",
|
||||
}
|
||||
|
||||
|
||||
def label_name(label: str | Mapping[str, object]) -> str:
|
||||
"""Return a normalized label name from a Gitea label object or string."""
|
||||
if isinstance(label, str):
|
||||
return label.strip()
|
||||
value = label.get("name")
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
"""Extract label names from a label list or issue-like mapping."""
|
||||
raw: object
|
||||
if isinstance(labels, Mapping):
|
||||
raw = labels.get("labels", [])
|
||||
else:
|
||||
raw = labels
|
||||
return [name for name in (label_name(label) for label in raw or []) if name]
|
||||
|
||||
|
||||
def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("type:")]
|
||||
|
||||
|
||||
def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("status:")]
|
||||
|
||||
|
||||
def canonical_status_label(status_or_transition: str) -> str:
|
||||
status = status_or_transition.strip()
|
||||
if status in STATUS_LABELS:
|
||||
return status
|
||||
normalized = status.lower().replace(" ", "-")
|
||||
try:
|
||||
return STATUS_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown workflow status or transition '{status_or_transition}'"
|
||||
) from exc
|
||||
|
||||
|
||||
def transition_status_labels(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
status_or_transition: str,
|
||||
) -> list[str]:
|
||||
"""Replace all active status labels with the requested canonical status."""
|
||||
new_status = canonical_status_label(status_or_transition)
|
||||
kept = [name for name in label_names(existing_labels) if not name.startswith("status:")]
|
||||
if new_status not in kept:
|
||||
kept.append(new_status)
|
||||
return kept
|
||||
|
||||
|
||||
def role_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("role:")]
|
||||
|
||||
|
||||
def hazard_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]:
|
||||
return [name for name in label_names(labels) if name.startswith("hazard:")]
|
||||
|
||||
|
||||
def canonical_role_label(role_or_transition: str) -> str:
|
||||
role = role_or_transition.strip()
|
||||
if role in ROLE_LABELS:
|
||||
return role
|
||||
normalized = role.lower().replace(" ", "-")
|
||||
try:
|
||||
return ROLE_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"unknown workflow role or transition '{role_or_transition}'"
|
||||
) from exc
|
||||
|
||||
|
||||
def transition_role_labels(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
role_or_transition: str,
|
||||
) -> list[str]:
|
||||
"""Replace all active role labels with the requested canonical role."""
|
||||
new_role = canonical_role_label(role_or_transition)
|
||||
kept = [name for name in label_names(existing_labels) if not name.startswith("role:")]
|
||||
if new_role not in kept:
|
||||
kept.append(new_role)
|
||||
return kept
|
||||
|
||||
|
||||
def canonical_hazard_label(hazard: str) -> str:
|
||||
name = hazard.strip()
|
||||
if name in HAZARD_LABELS:
|
||||
return name
|
||||
normalized = name.lower().replace(" ", "-")
|
||||
try:
|
||||
return HAZARD_TRANSITIONS[normalized]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unknown workflow hazard '{hazard}'") from exc
|
||||
|
||||
|
||||
def add_hazard_label(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
hazard: str,
|
||||
) -> list[str]:
|
||||
"""Add a hazard flag without disturbing status/role/type labels (additive)."""
|
||||
new_hazard = canonical_hazard_label(hazard)
|
||||
kept = label_names(existing_labels)
|
||||
if new_hazard not in kept:
|
||||
kept.append(new_hazard)
|
||||
return kept
|
||||
|
||||
|
||||
def clear_hazard_label(
|
||||
existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
hazard: str,
|
||||
) -> list[str]:
|
||||
"""Remove a single hazard flag, leaving all other labels intact."""
|
||||
target = canonical_hazard_label(hazard)
|
||||
return [name for name in label_names(existing_labels) if name != target]
|
||||
|
||||
|
||||
def is_discussion(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> bool:
|
||||
return "type:discussion" in type_labels(labels)
|
||||
|
||||
|
||||
def is_implementation_candidate(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
) -> bool:
|
||||
"""Whether an item may enter an implementation queue on labels alone.
|
||||
|
||||
Discussion issues are excluded (#603 AC3) unless a controller explicitly
|
||||
selects them; the allocator still cross-checks live lease/PR state and never
|
||||
trusts labels alone (#603 AC2).
|
||||
"""
|
||||
return not is_discussion(labels)
|
||||
|
||||
|
||||
def requires_blocking_reason(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
) -> bool:
|
||||
"""Whether the item must carry a blocking-reason / next-action comment (AC4).
|
||||
|
||||
True when blocked or when any hazard flag is present.
|
||||
"""
|
||||
names = label_names(labels)
|
||||
if "status:blocked" in names:
|
||||
return True
|
||||
return any(name.startswith("hazard:") for name in names)
|
||||
|
||||
|
||||
def labels_for_new_issue(
|
||||
issue_type: str | None = None,
|
||||
initial_status: str | None = None,
|
||||
extra_labels: Sequence[str] | None = None,
|
||||
*,
|
||||
discussion: bool = False,
|
||||
) -> list[str]:
|
||||
names = list(extra_labels or [])
|
||||
if issue_type:
|
||||
type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}"
|
||||
names.append(type_name)
|
||||
if discussion:
|
||||
names.append("type:discussion")
|
||||
if initial_status:
|
||||
names.append(canonical_status_label(initial_status))
|
||||
|
||||
result: list[str] = []
|
||||
for name in names:
|
||||
if name not in result:
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
|
||||
def assess_issue_labels(
|
||||
labels: Iterable[str | Mapping[str, object]] | Mapping[str, object],
|
||||
*,
|
||||
discussion: bool = False,
|
||||
require_type: bool = True,
|
||||
require_status: bool = True,
|
||||
) -> dict:
|
||||
names = label_names(labels)
|
||||
found_types = type_labels(names)
|
||||
found_statuses = status_labels(names)
|
||||
found_roles = role_labels(names)
|
||||
found_hazards = hazard_labels(names)
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if require_type and not found_types:
|
||||
errors.append("issue is missing a type:* label")
|
||||
if require_status and not found_statuses:
|
||||
errors.append("issue is missing a status:* label")
|
||||
if discussion and "type:discussion" not in found_types:
|
||||
errors.append("discussion issue is missing type:discussion")
|
||||
if len(found_statuses) > 1:
|
||||
errors.append(
|
||||
"issue has multiple active status:* labels: "
|
||||
+ ", ".join(found_statuses)
|
||||
)
|
||||
if len(found_roles) > 1:
|
||||
errors.append(
|
||||
"issue has multiple active role:* labels: " + ", ".join(found_roles)
|
||||
)
|
||||
|
||||
for name in found_types:
|
||||
if name not in TYPE_LABELS:
|
||||
warnings.append(f"unknown type label '{name}'")
|
||||
for name in found_statuses:
|
||||
if name not in STATUS_LABELS:
|
||||
warnings.append(f"unknown status label '{name}'")
|
||||
for name in found_roles:
|
||||
if name not in ROLE_LABELS:
|
||||
warnings.append(f"unknown role label '{name}'")
|
||||
for name in found_hazards:
|
||||
if name not in HAZARD_LABELS:
|
||||
warnings.append(f"unknown hazard label '{name}'")
|
||||
|
||||
return {
|
||||
"valid": not errors,
|
||||
"labels": names,
|
||||
"type_labels": found_types,
|
||||
"status_labels": found_statuses,
|
||||
"role_labels": found_roles,
|
||||
"hazard_labels": found_hazards,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
"""First-class control-plane lease lifecycle (#601).
|
||||
|
||||
Active leases are queryable workflow state. Canonical operations:
|
||||
|
||||
* list / inspect
|
||||
* adopt (with provenance)
|
||||
* release (explicit, recorded)
|
||||
* expire / reclaim
|
||||
* abandon (requires proof: dead process and/or missing worktree, etc.)
|
||||
|
||||
Authority model:
|
||||
|
||||
* Control-plane DB is the coordination source for assignment/lease.
|
||||
* File locks and comment-only leases are **not** authoritative alone.
|
||||
* Reviewer/merger comment leases (``reviewer_pr_lease`` / merger adoption)
|
||||
remain for Gitea-thread durability; they must not bypass DB assignment when
|
||||
the control-plane path is in use.
|
||||
|
||||
Fail closed on ambiguous ownership, active foreign leases, mismatched
|
||||
worktree, stale head, missing capability, and unsafe cleanup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import control_plane_db as cpd
|
||||
|
||||
# Outcomes / safe next actions (stable vocabulary for tools + tests).
|
||||
SAFE_OWNER_RESUME = "owner_resume"
|
||||
SAFE_WAIT_FOREIGN = "wait_foreign_active"
|
||||
SAFE_RECLAIM_EXPIRED = "reclaim_expired"
|
||||
SAFE_ABANDON_ALLOWED = "abandon_allowed"
|
||||
SAFE_RELEASE_OWNED = "release_owned"
|
||||
SAFE_STALE_PROMPT = "stale_prompt_lease"
|
||||
SAFE_UNKNOWN = "inspect_only"
|
||||
SAFE_NO_AUTHORITY = "file_or_comment_not_authoritative"
|
||||
|
||||
LEASE_STATUS_ACTIVE = "active"
|
||||
LEASE_STATUS_RELEASED = "released"
|
||||
LEASE_STATUS_EXPIRED = "expired"
|
||||
LEASE_STATUS_ABANDONED = "abandoned"
|
||||
|
||||
AUTHORITATIVE_SOURCE = "control_plane_db"
|
||||
|
||||
|
||||
class LeaseLifecycleError(RuntimeError):
|
||||
"""Fail-closed lifecycle policy error."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbandonProof:
|
||||
"""Required evidence to abandon a non-owned or expired lease safely."""
|
||||
|
||||
dead_process: bool = False
|
||||
missing_worktree: bool = False
|
||||
same_owner: bool = False
|
||||
no_open_pr: bool = False
|
||||
no_live_mutation_risk: bool = False
|
||||
operator_authorized: bool = False
|
||||
worktree_path: str | None = None
|
||||
owner_pid: int | None = None
|
||||
notes: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"dead_process": self.dead_process,
|
||||
"missing_worktree": self.missing_worktree,
|
||||
"same_owner": self.same_owner,
|
||||
"no_open_pr": self.no_open_pr,
|
||||
"no_live_mutation_risk": self.no_live_mutation_risk,
|
||||
"operator_authorized": self.operator_authorized,
|
||||
"worktree_path": self.worktree_path,
|
||||
"owner_pid": self.owner_pid,
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
def is_sufficient(self) -> bool:
|
||||
"""Abandon requires process death or missing worktree + no mutation risk.
|
||||
|
||||
Foreign active leases additionally need operator_authorized unless the
|
||||
owner process is dead and the worktree is missing.
|
||||
"""
|
||||
if not self.no_live_mutation_risk:
|
||||
return False
|
||||
if not (self.dead_process or self.missing_worktree):
|
||||
return False
|
||||
if self.same_owner:
|
||||
return True
|
||||
# Foreign abandon: operator flag OR (dead + missing worktree + no risk)
|
||||
if self.operator_authorized:
|
||||
return True
|
||||
return bool(self.dead_process and self.missing_worktree and self.no_open_pr)
|
||||
|
||||
|
||||
def is_process_alive(pid: int | None) -> bool:
|
||||
if pid is None:
|
||||
return False
|
||||
try:
|
||||
pid_i = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid_i <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid_i, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
# Exists but not owned by us — treat as alive (fail closed).
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def worktree_exists(path: str | None) -> bool:
|
||||
if not path:
|
||||
return False
|
||||
try:
|
||||
return os.path.isdir(os.path.realpath(path))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_ts(value: str | None) -> datetime | None:
|
||||
return cpd._parse_ts(value)
|
||||
|
||||
|
||||
def classify_lease_freshness(
|
||||
lease: Mapping[str, Any],
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
pid_checker: Callable[[int | None], bool] = is_process_alive,
|
||||
worktree_checker: Callable[[str | None], bool] = worktree_exists,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify a control-plane lease row for workflow tooling."""
|
||||
moment = now or _utc_now()
|
||||
status = (lease.get("status") or "").strip().lower()
|
||||
expires = _parse_ts(lease.get("expires_at"))
|
||||
owner_pid = lease.get("owner_pid")
|
||||
if owner_pid is None:
|
||||
owner_pid = lease.get("session_pid")
|
||||
wt = lease.get("worktree_path")
|
||||
pid_alive = pid_checker(owner_pid) if owner_pid is not None else None
|
||||
wt_present = worktree_checker(wt) if wt else None
|
||||
expired_by_time = bool(expires and expires <= moment)
|
||||
|
||||
if status == LEASE_STATUS_ABANDONED:
|
||||
freshness = "abandoned"
|
||||
elif status == LEASE_STATUS_RELEASED:
|
||||
freshness = "released"
|
||||
elif status == LEASE_STATUS_EXPIRED or expired_by_time:
|
||||
freshness = "expired"
|
||||
elif status != LEASE_STATUS_ACTIVE:
|
||||
freshness = status or "unknown"
|
||||
elif pid_alive is False:
|
||||
freshness = "stale_dead_process"
|
||||
elif wt is not None and wt_present is False:
|
||||
freshness = "stale_missing_worktree"
|
||||
else:
|
||||
freshness = "active"
|
||||
|
||||
return {
|
||||
"freshness": freshness,
|
||||
"status": status,
|
||||
"expired_by_time": expired_by_time,
|
||||
"owner_pid": owner_pid,
|
||||
"owner_pid_alive": pid_alive,
|
||||
"worktree_path": wt,
|
||||
"worktree_present": wt_present,
|
||||
"expires_at": lease.get("expires_at"),
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
|
||||
def decide_safe_next_action(
|
||||
*,
|
||||
lease: Mapping[str, Any] | None,
|
||||
caller_session_id: str,
|
||||
freshness: Mapping[str, Any] | None = None,
|
||||
caller_worktree: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return safe_next_action + reasons for a caller inspecting a lease."""
|
||||
if not lease:
|
||||
return {
|
||||
"safe_next_action": SAFE_STALE_PROMPT,
|
||||
"reasons": ["lease not found in control-plane DB (stale prompt id?)"],
|
||||
"block": True,
|
||||
}
|
||||
fr = freshness or classify_lease_freshness(lease)
|
||||
owner = str(lease.get("session_id") or "")
|
||||
same_owner = owner == str(caller_session_id)
|
||||
status = fr.get("freshness")
|
||||
reasons: list[str] = []
|
||||
|
||||
# Worktree mismatch for owner resume
|
||||
lease_wt = lease.get("worktree_path")
|
||||
if (
|
||||
same_owner
|
||||
and lease_wt
|
||||
and caller_worktree
|
||||
and os.path.realpath(str(lease_wt)) != os.path.realpath(str(caller_worktree))
|
||||
):
|
||||
return {
|
||||
"safe_next_action": SAFE_UNKNOWN,
|
||||
"reasons": [
|
||||
f"worktree mismatch: lease has {lease_wt!r}, caller has "
|
||||
f"{caller_worktree!r} (fail closed)"
|
||||
],
|
||||
"block": True,
|
||||
"same_owner": True,
|
||||
}
|
||||
|
||||
if status in ("abandoned", "released"):
|
||||
return {
|
||||
"safe_next_action": SAFE_STALE_PROMPT,
|
||||
"reasons": [f"lease status is {status}; do not adopt blindly"],
|
||||
"block": True,
|
||||
"same_owner": same_owner,
|
||||
}
|
||||
|
||||
if status == "expired" or fr.get("expired_by_time"):
|
||||
return {
|
||||
"safe_next_action": SAFE_RECLAIM_EXPIRED,
|
||||
"reasons": ["lease expired; reclaim via expire+assign or adopt reclaim path"],
|
||||
"block": False,
|
||||
"same_owner": same_owner,
|
||||
}
|
||||
|
||||
if status in ("stale_dead_process", "stale_missing_worktree"):
|
||||
if same_owner:
|
||||
return {
|
||||
"safe_next_action": SAFE_OWNER_RESUME,
|
||||
"reasons": [
|
||||
f"caller owns lease with freshness={status}; rebind via "
|
||||
"adopt/owner-resume or abandon with proof"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": True,
|
||||
"also_allowed": [SAFE_ABANDON_ALLOWED, SAFE_RELEASE_OWNED],
|
||||
}
|
||||
return {
|
||||
"safe_next_action": SAFE_ABANDON_ALLOWED,
|
||||
"reasons": [
|
||||
f"lease freshness={status}; abandon with required proof then reassign"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": False,
|
||||
}
|
||||
|
||||
if same_owner and status == "active":
|
||||
return {
|
||||
"safe_next_action": SAFE_OWNER_RESUME,
|
||||
"reasons": [
|
||||
"caller owns active lease; resume via heartbeat/assign owner-resume "
|
||||
"or release explicitly"
|
||||
],
|
||||
"block": False,
|
||||
"same_owner": True,
|
||||
"also_allowed": [SAFE_RELEASE_OWNED],
|
||||
}
|
||||
|
||||
if not same_owner and status == "active":
|
||||
return {
|
||||
"safe_next_action": SAFE_WAIT_FOREIGN,
|
||||
"reasons": [
|
||||
f"foreign active lease held by session {owner}; "
|
||||
"do not steal (fail closed)"
|
||||
],
|
||||
"block": True,
|
||||
"same_owner": False,
|
||||
"owner_session_id": owner,
|
||||
}
|
||||
|
||||
return {
|
||||
"safe_next_action": SAFE_UNKNOWN,
|
||||
"reasons": [f"unclassified freshness={status}"],
|
||||
"block": True,
|
||||
"same_owner": same_owner,
|
||||
}
|
||||
|
||||
|
||||
def non_db_lease_authority_report(
|
||||
*,
|
||||
file_lock_present: bool = False,
|
||||
comment_lease_present: bool = False,
|
||||
db_lease_present: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""File/comment leases alone are not coordination authority (#601 / #600)."""
|
||||
authoritative = bool(db_lease_present)
|
||||
reasons: list[str] = []
|
||||
if file_lock_present and not db_lease_present:
|
||||
reasons.append(
|
||||
"file lock present but control-plane DB lease absent — "
|
||||
"file lock is not authoritative alone"
|
||||
)
|
||||
if comment_lease_present and not db_lease_present:
|
||||
reasons.append(
|
||||
"comment-only lease present but control-plane DB lease absent — "
|
||||
"comment lease is not authoritative alone"
|
||||
)
|
||||
if authoritative:
|
||||
reasons.append("control-plane DB lease is the coordination authority")
|
||||
return {
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE if authoritative else None,
|
||||
"db_lease_present": db_lease_present,
|
||||
"file_lock_present": file_lock_present,
|
||||
"comment_lease_present": comment_lease_present,
|
||||
"safe_next_action": (
|
||||
SAFE_UNKNOWN if authoritative else SAFE_NO_AUTHORITY
|
||||
),
|
||||
"reasons": reasons,
|
||||
"file_lock_only": bool(file_lock_present and not db_lease_present),
|
||||
"comment_lease_only": bool(comment_lease_present and not db_lease_present),
|
||||
}
|
||||
|
||||
|
||||
def build_adopt_provenance(
|
||||
*,
|
||||
adopted_from_session_id: str,
|
||||
adopted_by_session_id: str,
|
||||
work_kind: str,
|
||||
work_number: int,
|
||||
remote: str,
|
||||
org: str,
|
||||
repo: str,
|
||||
worktree_path: str | None,
|
||||
expected_head_sha: str | None,
|
||||
prior_lease_id: str | None,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"adopted_from_session_id": adopted_from_session_id,
|
||||
"adopted_by_session_id": adopted_by_session_id,
|
||||
"work_kind": work_kind,
|
||||
"work_number": int(work_number),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"worktree_path": worktree_path,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"prior_lease_id": prior_lease_id,
|
||||
"reason": reason,
|
||||
"recorded_at": cpd._ts(),
|
||||
"source": "lease_lifecycle.adopt",
|
||||
}
|
||||
|
||||
|
||||
def inspect_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
lease_id: str,
|
||||
*,
|
||||
caller_session_id: str,
|
||||
caller_worktree: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full first-class lease workflow state for one lease id."""
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
decision = decide_safe_next_action(
|
||||
lease=None, caller_session_id=caller_session_id
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"found": False,
|
||||
"lease_id": lease_id,
|
||||
"lease": None,
|
||||
"freshness": None,
|
||||
**decision,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
lease = state["lease"]
|
||||
freshness = classify_lease_freshness(lease)
|
||||
decision = decide_safe_next_action(
|
||||
lease=lease,
|
||||
caller_session_id=caller_session_id,
|
||||
freshness=freshness,
|
||||
caller_worktree=caller_worktree,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"found": True,
|
||||
"lease_id": lease_id,
|
||||
"lease": lease,
|
||||
"assignment": state.get("assignment"),
|
||||
"work_item": state.get("work_item"),
|
||||
"session": state.get("session"),
|
||||
"freshness": freshness,
|
||||
"provenance": state.get("provenance"),
|
||||
**decision,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
|
||||
def list_active_leases(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
role: str | None = None,
|
||||
include_non_active: bool = False,
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
statuses = None if include_non_active else (LEASE_STATUS_ACTIVE,)
|
||||
rows = db.list_leases(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
role=role,
|
||||
statuses=statuses,
|
||||
limit=limit,
|
||||
)
|
||||
enriched = []
|
||||
for row in rows:
|
||||
fr = classify_lease_freshness(row)
|
||||
enriched.append({**row, "freshness": fr})
|
||||
return {
|
||||
"success": True,
|
||||
"count": len(enriched),
|
||||
"leases": enriched,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"include_non_active": include_non_active,
|
||||
}
|
||||
|
||||
|
||||
def adopt_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
lease_id: str,
|
||||
adopter_session_id: str,
|
||||
role: str,
|
||||
worktree_path: str | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
owner_pid: int | None = None,
|
||||
operator_authorized: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Sanctioned adopt path with provenance; never silent foreign steal."""
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
raise LeaseLifecycleError(
|
||||
f"unknown lease_id {lease_id}; stale prompt id (fail closed)"
|
||||
)
|
||||
lease = state["lease"]
|
||||
work = state["work_item"]
|
||||
freshness = classify_lease_freshness(lease)
|
||||
owner = str(lease.get("session_id") or "")
|
||||
same_owner = owner == str(adopter_session_id)
|
||||
|
||||
if freshness["freshness"] == "active" and not same_owner:
|
||||
raise LeaseLifecycleError(
|
||||
f"refusing to steal active foreign lease {lease_id} owned by "
|
||||
f"{owner} (fail closed)"
|
||||
)
|
||||
|
||||
if freshness["freshness"] in ("abandoned", "released"):
|
||||
raise LeaseLifecycleError(
|
||||
f"lease {lease_id} is {freshness['freshness']}; cannot adopt "
|
||||
"(fail closed)"
|
||||
)
|
||||
|
||||
# Expired or stale: require abandon-style safety before ownership transfer
|
||||
# when not same owner; same owner may reclaim.
|
||||
if not same_owner and freshness["freshness"] in (
|
||||
"expired",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
):
|
||||
if not operator_authorized and freshness["freshness"] == "expired":
|
||||
# Deterministic reclaim of expired foreign lease is allowed
|
||||
# without operator flag (sanctioned expire reclaim).
|
||||
pass
|
||||
elif freshness["freshness"] != "expired" and not operator_authorized:
|
||||
# stale active-looking requires abandon proof path
|
||||
raise LeaseLifecycleError(
|
||||
f"lease {lease_id} freshness={freshness['freshness']}; "
|
||||
"use abandon with proof before foreign adopt (fail closed)"
|
||||
)
|
||||
|
||||
provenance = build_adopt_provenance(
|
||||
adopted_from_session_id=owner,
|
||||
adopted_by_session_id=adopter_session_id,
|
||||
work_kind=str(work.get("kind")),
|
||||
work_number=int(work.get("number")),
|
||||
remote=str(work.get("remote")),
|
||||
org=str(work.get("org")),
|
||||
repo=str(work.get("repo")),
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha or lease.get("expected_head_sha"),
|
||||
prior_lease_id=lease_id,
|
||||
reason=(
|
||||
"owner-resume-adopt" if same_owner else "sanctioned-reclaim-adopt"
|
||||
),
|
||||
)
|
||||
|
||||
result = db.adopt_lease(
|
||||
lease_id=lease_id,
|
||||
adopter_session_id=adopter_session_id,
|
||||
role=role,
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha,
|
||||
owner_pid=owner_pid if owner_pid is not None else os.getpid(),
|
||||
provenance=provenance,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": result.get("outcome"),
|
||||
"same_owner": same_owner,
|
||||
"prior_lease_id": lease_id,
|
||||
"lease": result.get("lease"),
|
||||
"assignment": result.get("assignment"),
|
||||
"provenance": provenance,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
"reasons": result.get("reasons") or [],
|
||||
}
|
||||
|
||||
|
||||
def release_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
lease_id: str,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
proof = db.release_lease_recorded(lease_id=lease_id, session_id=session_id)
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": "released",
|
||||
"lease_id": lease_id,
|
||||
"session_id": session_id,
|
||||
"release_proof": proof,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
|
||||
def expire_leases(db: cpd.ControlPlaneDB) -> dict[str, Any]:
|
||||
n = db.expire_stale_leases()
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": "expired",
|
||||
"expired_count": int(n),
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
|
||||
def reclaim_expired_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
lease_id: str,
|
||||
session_id: str,
|
||||
role: str,
|
||||
worktree_path: str | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Expire-if-needed then assign to *session_id* for the same work item."""
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
|
||||
lease = state["lease"]
|
||||
work = state["work_item"]
|
||||
freshness = classify_lease_freshness(lease)
|
||||
if freshness["freshness"] == "active":
|
||||
if lease.get("session_id") != session_id:
|
||||
raise LeaseLifecycleError(
|
||||
f"cannot reclaim active foreign lease {lease_id} (fail closed)"
|
||||
)
|
||||
# owner resume
|
||||
return adopt_lease(
|
||||
db,
|
||||
lease_id=lease_id,
|
||||
adopter_session_id=session_id,
|
||||
role=role,
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=expected_head_sha,
|
||||
)
|
||||
if freshness["freshness"] not in (
|
||||
"expired",
|
||||
"stale_dead_process",
|
||||
"stale_missing_worktree",
|
||||
"abandoned",
|
||||
"released",
|
||||
):
|
||||
raise LeaseLifecycleError(
|
||||
f"lease {lease_id} freshness={freshness['freshness']} not reclaimable"
|
||||
)
|
||||
|
||||
# Ensure expired marker is applied
|
||||
db.expire_stale_leases()
|
||||
# If still active due to clock skew / non-time stale, force expire this lease
|
||||
refreshed = db.get_lease_workflow_state(lease_id)
|
||||
if refreshed and refreshed["lease"].get("status") == "active":
|
||||
db.force_expire_lease(lease_id, reason="reclaim_expired_lease")
|
||||
|
||||
head = expected_head_sha or work.get("current_head_sha")
|
||||
assigned = db.assign_and_lease(
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
remote=str(work["remote"]),
|
||||
org=str(work["org"]),
|
||||
repo=str(work["repo"]),
|
||||
kind=str(work["kind"]),
|
||||
number=int(work["number"]),
|
||||
expected_head_sha=head,
|
||||
phase="reclaimed",
|
||||
worktree_path=worktree_path,
|
||||
owner_pid=os.getpid(),
|
||||
)
|
||||
if assigned.outcome != "assigned":
|
||||
raise LeaseLifecycleError(
|
||||
f"reclaim assign failed: {assigned.outcome} — {assigned.reason}"
|
||||
)
|
||||
provenance = build_adopt_provenance(
|
||||
adopted_from_session_id=str(lease.get("session_id")),
|
||||
adopted_by_session_id=session_id,
|
||||
work_kind=str(work["kind"]),
|
||||
work_number=int(work["number"]),
|
||||
remote=str(work["remote"]),
|
||||
org=str(work["org"]),
|
||||
repo=str(work["repo"]),
|
||||
worktree_path=worktree_path,
|
||||
expected_head_sha=head,
|
||||
prior_lease_id=lease_id,
|
||||
reason="reclaim_expired",
|
||||
)
|
||||
db.attach_lease_provenance(assigned.lease_id, provenance)
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": "reclaimed",
|
||||
"prior_lease_id": lease_id,
|
||||
"assignment": assigned.as_dict(),
|
||||
"provenance": provenance,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
|
||||
|
||||
def abandon_lease(
|
||||
db: cpd.ControlPlaneDB,
|
||||
*,
|
||||
lease_id: str,
|
||||
requester_session_id: str,
|
||||
proof: AbandonProof,
|
||||
) -> dict[str, Any]:
|
||||
state = db.get_lease_workflow_state(lease_id)
|
||||
if not state:
|
||||
raise LeaseLifecycleError(f"unknown lease_id {lease_id}")
|
||||
lease = state["lease"]
|
||||
owner = str(lease.get("session_id") or "")
|
||||
same_owner = owner == str(requester_session_id)
|
||||
|
||||
# Enrich proof with live checks when not provided
|
||||
owner_pid = proof.owner_pid
|
||||
if owner_pid is None:
|
||||
owner_pid = lease.get("owner_pid")
|
||||
wt = proof.worktree_path if proof.worktree_path is not None else lease.get(
|
||||
"worktree_path"
|
||||
)
|
||||
dead = proof.dead_process or (
|
||||
owner_pid is not None and not is_process_alive(owner_pid)
|
||||
)
|
||||
missing_wt = proof.missing_worktree or (
|
||||
bool(wt) and not worktree_exists(str(wt))
|
||||
)
|
||||
enriched = AbandonProof(
|
||||
dead_process=bool(dead),
|
||||
missing_worktree=bool(missing_wt),
|
||||
same_owner=same_owner or proof.same_owner,
|
||||
no_open_pr=proof.no_open_pr,
|
||||
no_live_mutation_risk=proof.no_live_mutation_risk,
|
||||
operator_authorized=proof.operator_authorized,
|
||||
worktree_path=str(wt) if wt else None,
|
||||
owner_pid=int(owner_pid) if owner_pid is not None else None,
|
||||
notes=proof.notes,
|
||||
)
|
||||
if not enriched.is_sufficient():
|
||||
raise LeaseLifecycleError(
|
||||
"abandon proof insufficient: need (dead_process or missing_worktree) "
|
||||
"and no_live_mutation_risk; foreign abandon also needs "
|
||||
"operator_authorized or (dead+missing_worktree+no_open_pr). "
|
||||
f"proof={enriched.as_dict()}"
|
||||
)
|
||||
|
||||
# Active foreign with live process + present worktree: never abandon without
|
||||
# operator (already covered by is_sufficient).
|
||||
result = db.abandon_lease(
|
||||
lease_id=lease_id,
|
||||
requester_session_id=requester_session_id,
|
||||
proof=enriched.as_dict(),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"outcome": "abandoned",
|
||||
"lease_id": lease_id,
|
||||
"requester_session_id": requester_session_id,
|
||||
"prior_owner_session_id": owner,
|
||||
"abandon_proof": enriched.as_dict(),
|
||||
"audit": result,
|
||||
"authoritative_source": AUTHORITATIVE_SOURCE,
|
||||
"file_lock_only": False,
|
||||
"comment_lease_only": False,
|
||||
}
|
||||
+17
-6
@@ -22,7 +22,8 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
|
||||
if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
os.execv(venv_python, [venv_python] + sys.argv)
|
||||
|
||||
from gitea_auth import get_auth_header, api_request, repo_api_url
|
||||
from gitea_auth import get_auth_header, api_request, api_get_all, repo_api_url
|
||||
import issue_workflow_labels
|
||||
|
||||
HOST = "gitea.dadeschools.net"
|
||||
ORG = "Contractor"
|
||||
@@ -35,8 +36,10 @@ LABELS = [
|
||||
{"name": "epic", "color": "8250df", "description": ""},
|
||||
{"name": "important", "color": "fbca04", "description": ""},
|
||||
{"name": "nice-to-have", "color": "0e8a16", "description": ""},
|
||||
{"name": "status:in-progress", "color": "fefe2e",
|
||||
"description": "Issue is being worked on"},
|
||||
*[
|
||||
{"name": spec.name, "color": spec.color, "description": spec.description}
|
||||
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS
|
||||
],
|
||||
]
|
||||
|
||||
# issue number -> label names to apply (one-off backfill)
|
||||
@@ -79,9 +82,17 @@ def api(method, path, auth, payload=None):
|
||||
|
||||
|
||||
def _labels_by_name(auth):
|
||||
"""Return {label name: id} for the repo's existing labels."""
|
||||
existing = api("GET", "/labels?limit=100", auth) or []
|
||||
return {lb["name"]: lb["id"] for lb in existing}
|
||||
"""Return {label name: id} for the repo's existing labels (all pages, #627)."""
|
||||
existing = api_get_all(f"{BASE_URL}/labels", auth) or []
|
||||
name_to_id = {}
|
||||
for lb in existing:
|
||||
if not isinstance(lb, dict):
|
||||
continue
|
||||
name = lb.get("name")
|
||||
lid = lb.get("id")
|
||||
if name and lid is not None and name not in name_to_id:
|
||||
name_to_id[name] = lid
|
||||
return name_to_id
|
||||
|
||||
|
||||
def create_labels(auth, dry=False):
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
|
||||
from gitea_auth import (
|
||||
get_auth_header, resolve_remote, add_remote_args,
|
||||
api_request, repo_api_url,
|
||||
api_request, api_get_all, repo_api_url,
|
||||
)
|
||||
|
||||
LABEL_NAME = "status:in-progress"
|
||||
@@ -45,12 +45,12 @@ def main(argv=None):
|
||||
base = repo_api_url(host, org, repo)
|
||||
|
||||
try:
|
||||
# Find the label ID
|
||||
labels = api_request("GET", f"{base}/labels?limit=100", auth)
|
||||
# Paginated inventory (#627): Gitea caps single pages at 50.
|
||||
labels = api_get_all(f"{base}/labels", auth) or []
|
||||
label_id = None
|
||||
for lb in labels:
|
||||
if lb["name"] == LABEL_NAME:
|
||||
label_id = lb["id"]
|
||||
if lb.get("name") == LABEL_NAME:
|
||||
label_id = lb.get("id")
|
||||
break
|
||||
|
||||
if label_id is None:
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Master-parity staleness gate (#420).
|
||||
|
||||
The Gitea MCP server loads its capability-gate code and workflow logic into
|
||||
memory when the process starts. When ``master`` advances -- for example a newly
|
||||
merged security gate such as the branch-delete capability gate (#408/#410) --
|
||||
the running process keeps executing the *old* code until it is restarted. A
|
||||
stale server can therefore still perform a mutation that the updated codebase
|
||||
would forbid.
|
||||
|
||||
Runtime profile/config data is already read live from disk on every call
|
||||
(``gitea_config.load_config`` re-reads the JSON file each time), so profile
|
||||
``allowed_operations`` changes take effect immediately without a restart. The
|
||||
gap this module closes is *code* parity: it captures the server process's
|
||||
source-tree commit at startup and detects, at mutation time, when the on-disk
|
||||
``master`` HEAD has advanced past it. Detected staleness fails closed with a
|
||||
restart-required recovery report, while read-only operations stay allowed so a
|
||||
stale server can still be inspected.
|
||||
|
||||
The core assessment is pure -- callers inject the observed HEAD SHAs -- so the
|
||||
logic is fully unit-testable without a git checkout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Environment escape hatches (ops + tests):
|
||||
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
||||
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
||||
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
||||
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
||||
|
||||
|
||||
def read_git_head(root: str) -> str | None:
|
||||
"""Return the current ``HEAD`` commit SHA of *root*, or ``None``.
|
||||
|
||||
``None`` means the SHA could not be determined (not a git checkout, git
|
||||
unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD``
|
||||
takes precedence so the gate can be exercised deterministically.
|
||||
"""
|
||||
forced = os.environ.get(ENV_TEST_CURRENT_HEAD)
|
||||
if forced is not None:
|
||||
return forced.strip() or None
|
||||
if not root:
|
||||
return None
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "-C", root, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
return (res.stdout or "").strip() or None
|
||||
|
||||
|
||||
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
||||
"""Capture the process source-tree baseline once at server startup.
|
||||
|
||||
*head* may be injected (tests); otherwise it is read from *root*. The result
|
||||
is an opaque baseline handed back to :func:`assess_master_parity`.
|
||||
"""
|
||||
startup_head = head if head is not None else read_git_head(root)
|
||||
return {"root": root, "startup_head": startup_head}
|
||||
|
||||
|
||||
def _short(sha: str | None) -> str:
|
||||
return sha[:12] if sha else "unknown"
|
||||
|
||||
|
||||
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
||||
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
||||
|
||||
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
||||
|
||||
- ``in_parity`` -- server code matches the on-disk master (or parity
|
||||
could not be determined, which is not treated as stale).
|
||||
- ``stale`` -- the on-disk master has definitively advanced past the
|
||||
running process.
|
||||
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
||||
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
||||
- ``startup_head`` / ``current_head`` / ``reasons``.
|
||||
"""
|
||||
startup_head = (startup or {}).get("startup_head")
|
||||
reasons: list[str] = []
|
||||
|
||||
if startup_head is None:
|
||||
reasons.append(
|
||||
"startup commit was not captured; code parity cannot be enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
|
||||
if current_head is None:
|
||||
reasons.append(
|
||||
"current workspace HEAD could not be read; code parity cannot be "
|
||||
"enforced")
|
||||
return _result(True, False, False, startup_head, current_head, reasons)
|
||||
|
||||
if startup_head == current_head:
|
||||
return _result(True, False, True, startup_head, current_head, reasons)
|
||||
|
||||
reasons.append(
|
||||
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
||||
f"master is now {_short(current_head)}; restart the server to load the "
|
||||
f"current capability gates")
|
||||
return _result(False, True, True, startup_head, current_head, reasons)
|
||||
|
||||
|
||||
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
||||
return {
|
||||
"in_parity": in_parity,
|
||||
"stale": stale,
|
||||
"restart_required": stale,
|
||||
"determinable": determinable,
|
||||
"startup_head": startup_head,
|
||||
"current_head": current_head,
|
||||
"reasons": list(reasons),
|
||||
}
|
||||
|
||||
|
||||
def gate_disabled() -> bool:
|
||||
"""Whether the parity gate is disabled by env escape hatch."""
|
||||
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
||||
|
||||
|
||||
def parity_block_reasons(assessment: dict) -> list[str]:
|
||||
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
||||
|
||||
A disabled gate or an in-parity / non-determinable assessment yields no
|
||||
reasons; only a definitively stale server blocks.
|
||||
"""
|
||||
if gate_disabled():
|
||||
return []
|
||||
if assessment.get("stale"):
|
||||
return list(assessment.get("reasons") or
|
||||
["server code is stale relative to master (fail closed)"])
|
||||
return []
|
||||
|
||||
|
||||
def parity_report(assessment: dict) -> dict:
|
||||
"""Structured stale-server report for permission-block payloads."""
|
||||
return {
|
||||
"kind": "server_stale",
|
||||
"restart_required": True,
|
||||
"startup_head": assessment.get("startup_head"),
|
||||
"current_head": assessment.get("current_head"),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"recovery": [
|
||||
"The running MCP server is executing code older than the current "
|
||||
"master and may not enforce newly merged capability gates.",
|
||||
"Restart the Gitea MCP server so it reloads master's capability "
|
||||
"gates and execution profiles before retrying the mutation.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_parity(assessment: dict) -> str:
|
||||
"""One-line human summary for logs / runtime context."""
|
||||
if assessment.get("stale"):
|
||||
return (f"STALE: started {_short(assessment.get('startup_head'))}, "
|
||||
f"master now {_short(assessment.get('current_head'))} "
|
||||
f"(restart required)")
|
||||
if not assessment.get("determinable"):
|
||||
return "parity indeterminate (baseline or current HEAD unknown)"
|
||||
return f"in parity at {_short(assessment.get('current_head'))}"
|
||||
Executable
+269
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env bash
|
||||
# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding.
|
||||
#
|
||||
# Safe by default: read-only status and copy-paste prompts unless an action is
|
||||
# explicitly labeled and confirmed. No branch deletion, force-push, lock-file
|
||||
# editing, or raw API bypass.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
|
||||
pause() {
|
||||
read -r -p "Press Enter to return to the menu..."
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
printf '\n=== Gitea-Tools MCP Operator Menu ===\n'
|
||||
printf 'Repository: %s\n' "$REPO_ROOT"
|
||||
printf 'Safe by default — destructive actions require explicit confirmation.\n\n'
|
||||
}
|
||||
|
||||
show_root_checkout_health() {
|
||||
printf '\n--- Project status / root checkout health ---\n\n'
|
||||
printf 'Current directory: %s\n' "$(pwd)"
|
||||
local branch head_sha prgs_master_sha dirty
|
||||
branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)"
|
||||
if [[ -z "$branch" ]]; then
|
||||
branch="(detached HEAD)"
|
||||
fi
|
||||
printf 'Current branch: %s\n' "$branch"
|
||||
printf '\nGit status (short, branch):\n'
|
||||
git -C "$REPO_ROOT" status --short --branch || true
|
||||
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')"
|
||||
printf '\nHEAD SHA: %s\n' "$head_sha"
|
||||
if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then
|
||||
prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)"
|
||||
printf 'prgs/master SHA: %s\n' "$prgs_master_sha"
|
||||
if [[ "$head_sha" != "$prgs_master_sha" ]]; then
|
||||
printf '\nWARNING: root checkout HEAD does not match prgs/master.\n'
|
||||
printf 'Keep the stable control checkout on master/prgs/master.\n'
|
||||
fi
|
||||
else
|
||||
printf 'prgs/master SHA: unavailable (remote ref not fetched)\n'
|
||||
fi
|
||||
if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then
|
||||
printf '\nWARNING: root checkout has uncommitted changes (dirty).\n'
|
||||
printf 'Author mutations belong in a session worktree under branches/.\n'
|
||||
fi
|
||||
if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then
|
||||
printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n'
|
||||
printf 'Return to master before using the control checkout.\n'
|
||||
fi
|
||||
pause
|
||||
}
|
||||
|
||||
print_prompt_block() {
|
||||
local title="$1"
|
||||
local body="$2"
|
||||
printf '\n--- %s ---\n\n' "$title"
|
||||
printf '%s\n' "$body"
|
||||
printf '\n(Copy the prompt above into your LLM session.)\n'
|
||||
pause
|
||||
}
|
||||
|
||||
show_author_prompts() {
|
||||
while true; do
|
||||
printf '\n--- Author workflow prompts ---\n'
|
||||
printf ' 1) Work issue (author/coder)\n'
|
||||
printf ' 2) Conflict-fix author session\n'
|
||||
printf ' 3) Root checkout recovery session\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Author — work issue" \
|
||||
"You are the AUTHOR session for <org>/<repo>.
|
||||
|
||||
Goal: implement issue #<N> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/.
|
||||
2. gitea_lock_issue for issue #<N> and branch feat/issue-<N>-<short-desc>.
|
||||
3. Implement in the locked worktree only — never mutate the root control checkout.
|
||||
4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger."
|
||||
;;
|
||||
2)
|
||||
print_prompt_block "Author — conflict-fix session" \
|
||||
"You are the AUTHOR session for <org>/<repo> in conflict-fix mode.
|
||||
|
||||
Goal: resolve merge conflicts on PR #<P> / branch <branch> only.
|
||||
|
||||
Workflow:
|
||||
1. Preflight: prove author identity and exact push/commit capability for the locked PR branch.
|
||||
2. Confirm conflict-fix lease and stale-head protection before pushing.
|
||||
3. Work only in the session-owned worktree under branches/ — never the root checkout.
|
||||
4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval.
|
||||
5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger."
|
||||
;;
|
||||
3)
|
||||
print_prompt_block "Author — root checkout recovery" \
|
||||
"You are a RECOVERY session for <org>/<repo>.
|
||||
|
||||
Goal: restore the stable root control checkout to clean master/prgs/master.
|
||||
|
||||
Workflow:
|
||||
1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master.
|
||||
2. Do not implement features from the root checkout. Stash or move work to branches/<session> first.
|
||||
3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files.
|
||||
4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation."
|
||||
;;
|
||||
0) return ;;
|
||||
*) printf 'Invalid choice.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_reviewer_prompts() {
|
||||
while true; do
|
||||
printf '\n--- Reviewer workflow prompts ---\n'
|
||||
printf ' 1) Standard PR review\n'
|
||||
printf ' 2) Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author\n'
|
||||
printf ' 0) Back\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1)
|
||||
print_prompt_block "Reviewer — PR review" \
|
||||
"You are the REVIEWER session for <org>/<repo>.
|
||||
|
||||
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove reviewer identity, review_pr capability, clean review worktree.
|
||||
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
|
||||
4. gitea_review_pr with approve, request-changes, or comment as warranted.
|
||||
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
|
||||
;;
|
||||
2)
|
||||
print_prompt_block "Reviewer — skip already-reviewed stale REQUEST_CHANGES PR and hand off to author" \
|
||||
"You are the REVIEWER session for <org>/<repo>.
|
||||
|
||||
Goal: review PR #<P> only far enough to determine whether the current head already has a non-stale REQUEST_CHANGES verdict. If it does, do NOT submit another terminal review mutation — produce an author handoff and stop.
|
||||
|
||||
Workflow:
|
||||
1. gitea_view_pr; pin the current head SHA.
|
||||
2. Load prior reviews; find the latest REQUEST_CHANGES and the head SHA it was bound to.
|
||||
3. If that REQUEST_CHANGES is still bound to the current head (non-stale), do not submit another terminal review mutation. Confirm the binding and summarize the blockers.
|
||||
4. Run only the diagnostics needed to produce a useful author handoff — no full re-review.
|
||||
5. Duplicate/supersession check: confirm no newer canonical PR or superseding head changes the decision.
|
||||
6. Stop — no new review mutation.
|
||||
|
||||
Return:
|
||||
- selected PR and issue
|
||||
- current head SHA
|
||||
- prior REQUEST_CHANGES proof (review id + bound head SHA)
|
||||
- duplicate/supersession analysis
|
||||
- validation summary
|
||||
- why no new review mutation was submitted
|
||||
- corrected mutation ledger, including local worktree create/remove if used
|
||||
- author-ready fix prompt"
|
||||
;;
|
||||
0) return ;;
|
||||
*) printf 'Invalid choice.\n' ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
show_merger_prompts() {
|
||||
print_prompt_block "Merger — PR merge" \
|
||||
"You are the MERGER session for <org>/<repo>.
|
||||
|
||||
Goal: merge PR #<P> only after every gate passes.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
|
||||
2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA.
|
||||
3. Confirm approval pins the current head SHA; re-validate if the branch moved.
|
||||
4. gitea_merge_pr only on explicit operator approval after all gates pass.
|
||||
5. Final report: merged SHA, cleanup handoff, mutation ledger."
|
||||
}
|
||||
|
||||
show_reconciler_prompts() {
|
||||
print_prompt_block "Reconciler — already-landed / closed PR cleanup" \
|
||||
"You are the RECONCILER session for <org>/<repo>.
|
||||
|
||||
Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven.
|
||||
|
||||
Workflow:
|
||||
1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md
|
||||
2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability.
|
||||
3. Do not review, merge, implement code, or create branches.
|
||||
4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate.
|
||||
5. Final report: PR numbers handled, close proof, mutation ledger."
|
||||
}
|
||||
|
||||
show_onboarding_prompt() {
|
||||
print_prompt_block "Onboarding — new project to MCP workflow" \
|
||||
"Onboard <org>/<repo> into the MCP Control Plane workflow.
|
||||
|
||||
Checklist:
|
||||
1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability.
|
||||
2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed.
|
||||
3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config.
|
||||
4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install.
|
||||
5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/.
|
||||
6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md.
|
||||
|
||||
Canonical router: skills/llm-project-workflow/SKILL.md"
|
||||
}
|
||||
|
||||
show_proxmox_placeholder() {
|
||||
printf '\n--- Proxmox deployment (placeholder) ---\n\n'
|
||||
printf 'Push this project to Proxmox — TODO / issue-backed\n'
|
||||
printf 'Create Proxmox LXC — TODO / issue-backed\n\n'
|
||||
printf 'These actions are NOT implemented yet.\n'
|
||||
printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n'
|
||||
printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n'
|
||||
pause
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
printf '\n--- Run tests ---\n\n'
|
||||
if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then
|
||||
printf 'Running ./run-tests.sh ...\n\n'
|
||||
(cd "$REPO_ROOT" && ./run-tests.sh)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then
|
||||
printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n'
|
||||
(cd "$REPO_ROOT" && ./venv/bin/python -m pytest)
|
||||
pause
|
||||
return
|
||||
fi
|
||||
printf 'ERROR: No test runner available (fail closed).\n' >&2
|
||||
printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
printf ' 1) Project status / root checkout health\n'
|
||||
printf ' 2) Author workflow prompts\n'
|
||||
printf ' 3) Reviewer workflow prompts\n'
|
||||
printf ' 4) Merger workflow prompts\n'
|
||||
printf ' 5) Reconciler workflow prompts\n'
|
||||
printf ' 6) Onboarding new project to this MCP workflow\n'
|
||||
printf ' 7) Proxmox deployment menu placeholder\n'
|
||||
printf ' 8) Create Proxmox LXC placeholder\n'
|
||||
printf ' 9) Run tests\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -r -p 'Choice: ' choice
|
||||
case "$choice" in
|
||||
1) show_root_checkout_health ;;
|
||||
2) show_author_prompts ;;
|
||||
3) show_reviewer_prompts ;;
|
||||
4) show_merger_prompts ;;
|
||||
5) show_reconciler_prompts ;;
|
||||
6) show_onboarding_prompt ;;
|
||||
7|8) show_proxmox_placeholder ;;
|
||||
9) run_tests ;;
|
||||
0) printf 'Goodbye.\n'; exit 0 ;;
|
||||
*) printf 'Invalid choice.\n'; pause ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main_menu
|
||||
@@ -0,0 +1,517 @@
|
||||
"""Sanctioned MCP daemon guards for imports and credential access (#558 / #695).
|
||||
|
||||
Direct ``import gitea_mcp_server`` from a shell bypasses native MCP transport.
|
||||
#558 introduced a daemon marker; #695 hardens it so:
|
||||
|
||||
- Environment variables alone cannot reconstruct a native session
|
||||
(``GITEA_MCP_SANCTIONED_DAEMON=1`` / ``GITEA_ALLOW_DIRECT_MCP_IMPORT=1`` are
|
||||
insufficient for mutation gates).
|
||||
- A process-local runtime record is established only by the resolved canonical
|
||||
entrypoint path (not basename) **and** the actual native MCP transport
|
||||
lifecycle (``bind_native_mcp_transport`` before ``mcp.run``). Merely
|
||||
importing or launching the entrypoint offline does not grant mutation
|
||||
authority.
|
||||
- Public caller-controlled flags (including any former
|
||||
``allow_test_bootstrap``) never establish trusted mutation provenance.
|
||||
- Offline scripts that import internals fail closed on mutations.
|
||||
- Pytest remains allowed for hermetic unit tests via ``is_pytest_runtime()``.
|
||||
A separate test-only seam may establish a **test-mode** native record for
|
||||
unit tests of transport gates; that record cannot authorize production
|
||||
Gitea mutation endpoints.
|
||||
|
||||
Manual deletion of session-state files is never a recovery path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SANCTIONED_DAEMON_ENV = "GITEA_MCP_SANCTIONED_DAEMON"
|
||||
ALLOW_DIRECT_IMPORT_ENV = "GITEA_ALLOW_DIRECT_MCP_IMPORT"
|
||||
ALLOW_KEYCHAIN_CLI_ENV = "GITEA_ALLOW_KEYCHAIN_CLI"
|
||||
# Test-only: force provenance failure even under pytest (#695 regressions).
|
||||
FORCE_PROVENANCE_FAIL_ENV = "GITEA_TEST_FORCE_UNSANCTIONED"
|
||||
|
||||
# Process-local native runtime (never persisted, never read from env alone).
|
||||
_NATIVE_RUNTIME: dict[str, Any] | None = None
|
||||
|
||||
# Production transport identifiers accepted by bind_native_mcp_transport.
|
||||
_PRODUCTION_TRANSPORTS = frozenset({"stdio"})
|
||||
_RUNTIME_MODE_PRODUCTION = "production"
|
||||
_RUNTIME_MODE_TEST = "test"
|
||||
_PHASE_ENTRYPOINT_CLAIMED = "entrypoint_claimed"
|
||||
_PHASE_TRANSPORT_BOUND = "transport_bound"
|
||||
|
||||
# Default session-state root (mirrors mcp_session_state; kept local to avoid
|
||||
# import cycles). Used only to pin authority at transport bind (#695 AC2).
|
||||
_DEFAULT_SESSION_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||
SESSION_STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||
|
||||
|
||||
class UnsanctionedRuntimeError(RuntimeError):
|
||||
"""Raised when mutation/credential code runs outside a native MCP daemon."""
|
||||
|
||||
|
||||
def is_pytest_runtime() -> bool:
|
||||
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
return False
|
||||
import sys
|
||||
|
||||
if "pytest" in sys.modules:
|
||||
return True
|
||||
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||
|
||||
|
||||
def _package_root() -> Path:
|
||||
"""Directory that contains the canonical MCP entrypoint modules."""
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def canonical_entrypoint_paths() -> frozenset[str]:
|
||||
"""Resolved absolute paths of official entrypoints (not basenames)."""
|
||||
root = _package_root()
|
||||
return frozenset(
|
||||
{
|
||||
str((root / "mcp_server.py").resolve()),
|
||||
str((root / "gitea_mcp_server.py").resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_path(path: str | None) -> str | None:
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return str(Path(path).resolve())
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _caller_official_entrypoint_path() -> str | None:
|
||||
"""Return the resolved canonical entrypoint path in the call stack, or None.
|
||||
|
||||
Basename-only matches (e.g. an attacker file named ``mcp_server.py``
|
||||
elsewhere) are rejected. The path must equal one of
|
||||
:func:`canonical_entrypoint_paths`.
|
||||
"""
|
||||
canonical = canonical_entrypoint_paths()
|
||||
for frame in inspect.stack()[1:20]:
|
||||
resolved = _resolve_path(frame.filename)
|
||||
if resolved and resolved in canonical:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _caller_is_official_entrypoint() -> bool:
|
||||
"""True when invoked from a resolved canonical entrypoint path (#695)."""
|
||||
return _caller_official_entrypoint_path() is not None
|
||||
|
||||
|
||||
def _new_runtime_token() -> tuple[str, str]:
|
||||
token = secrets.token_hex(32)
|
||||
fingerprint = hashlib.sha256(token.encode()).hexdigest()[:16]
|
||||
return token, fingerprint
|
||||
|
||||
|
||||
def mark_sanctioned_daemon() -> dict[str, Any]:
|
||||
"""Claim the official entrypoint for this process (#695).
|
||||
|
||||
This alone does **not** authorize mutations. Callers must subsequently
|
||||
bind the native MCP transport via :func:`bind_native_mcp_transport`.
|
||||
|
||||
Only a stack frame whose **resolved absolute path** is the canonical
|
||||
``mcp_server.py`` or ``gitea_mcp_server.py`` next to this module may
|
||||
claim the entrypoint. Basename spoofing is rejected.
|
||||
|
||||
There is no public ``allow_test_bootstrap`` argument: caller-controlled
|
||||
flags must never establish trusted mutation provenance. Hermetic tests
|
||||
use :func:`install_test_native_runtime` (pytest-only, test mode).
|
||||
"""
|
||||
global _NATIVE_RUNTIME
|
||||
if is_pytest_runtime():
|
||||
# Under pytest, production mark is a no-op for transport authority.
|
||||
# Tests that need a native-transport record use install_test_native_runtime.
|
||||
return native_runtime_status()
|
||||
|
||||
entrypoint_path = _caller_official_entrypoint_path()
|
||||
if entrypoint_path is None:
|
||||
raise UnsanctionedRuntimeError(
|
||||
"mark_sanctioned_daemon rejected: not called from the resolved "
|
||||
"canonical MCP entrypoint path (#695). Basename-only names "
|
||||
"(e.g. a renamed runner called mcp_server.py) are insufficient. "
|
||||
"Offline import / standalone scripts cannot reconstruct native "
|
||||
"transport. Stop after native MCP failure; do not run offline "
|
||||
"mutation helpers."
|
||||
)
|
||||
|
||||
token, fingerprint = _new_runtime_token()
|
||||
_NATIVE_RUNTIME = {
|
||||
"token": token,
|
||||
"token_fingerprint": fingerprint,
|
||||
"pid": os.getpid(),
|
||||
"started_at": time.time(),
|
||||
"entrypoint": "mcp_server",
|
||||
"entrypoint_path": entrypoint_path,
|
||||
"phase": _PHASE_ENTRYPOINT_CLAIMED,
|
||||
"transport": None,
|
||||
"mode": _RUNTIME_MODE_PRODUCTION,
|
||||
}
|
||||
# Legacy signal for older probes; alone does not authorize mutations.
|
||||
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||
return native_runtime_status()
|
||||
|
||||
|
||||
def bind_native_mcp_transport(*, transport: str) -> dict[str, Any]:
|
||||
"""Bind the live native MCP transport lifecycle (#695).
|
||||
|
||||
Must be called from the resolved canonical entrypoint immediately before
|
||||
the real MCP server transport loop (e.g. ``mcp.run(transport=\"stdio\")``).
|
||||
Requires a prior successful :func:`mark_sanctioned_daemon` claim in this
|
||||
process. Import-only or offline launch without this bind leaves
|
||||
:func:`is_native_mcp_transport` false.
|
||||
"""
|
||||
global _NATIVE_RUNTIME
|
||||
transport_name = (transport or "").strip().lower()
|
||||
if transport_name not in _PRODUCTION_TRANSPORTS:
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"bind_native_mcp_transport rejected: transport {transport!r} is "
|
||||
f"not a production MCP transport (#695). Allowed: "
|
||||
f"{sorted(_PRODUCTION_TRANSPORTS)}."
|
||||
)
|
||||
|
||||
entrypoint_path = _caller_official_entrypoint_path()
|
||||
if entrypoint_path is None:
|
||||
raise UnsanctionedRuntimeError(
|
||||
"bind_native_mcp_transport rejected: not called from the resolved "
|
||||
"canonical MCP entrypoint path (#695)."
|
||||
)
|
||||
|
||||
if _NATIVE_RUNTIME is None or int(_NATIVE_RUNTIME.get("pid") or -1) != os.getpid():
|
||||
raise UnsanctionedRuntimeError(
|
||||
"bind_native_mcp_transport rejected: no entrypoint claim in this "
|
||||
"process (#695). Call mark_sanctioned_daemon() from the official "
|
||||
"entrypoint first."
|
||||
)
|
||||
|
||||
if _NATIVE_RUNTIME.get("mode") != _RUNTIME_MODE_PRODUCTION:
|
||||
raise UnsanctionedRuntimeError(
|
||||
"bind_native_mcp_transport rejected: runtime mode is not "
|
||||
"production (#695)."
|
||||
)
|
||||
|
||||
claimed = (_NATIVE_RUNTIME.get("entrypoint_path") or "").strip()
|
||||
if claimed and claimed != entrypoint_path:
|
||||
raise UnsanctionedRuntimeError(
|
||||
"bind_native_mcp_transport rejected: entrypoint path mismatch "
|
||||
"between mark and bind (#695)."
|
||||
)
|
||||
|
||||
# Pin session-state root for this server lifetime (#695 AC2 / PR #701).
|
||||
# Changing GITEA_MCP_SESSION_STATE_DIR after bind must not manufacture a
|
||||
# second authority domain for decision locks / workflow proofs.
|
||||
raw_state = (os.environ.get(SESSION_STATE_DIR_ENV) or "").strip()
|
||||
if not raw_state:
|
||||
raw_state = _DEFAULT_SESSION_STATE_DIR
|
||||
try:
|
||||
pinned_state = str(Path(raw_state).resolve())
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pinned_state = raw_state
|
||||
|
||||
_NATIVE_RUNTIME["phase"] = _PHASE_TRANSPORT_BOUND
|
||||
_NATIVE_RUNTIME["transport"] = transport_name
|
||||
_NATIVE_RUNTIME["entrypoint_path"] = entrypoint_path
|
||||
_NATIVE_RUNTIME["bound_at"] = time.time()
|
||||
_NATIVE_RUNTIME["session_state_dir"] = pinned_state
|
||||
os.environ[SANCTIONED_DAEMON_ENV] = "1"
|
||||
return native_runtime_status()
|
||||
|
||||
|
||||
def install_test_native_runtime() -> dict[str, Any]:
|
||||
"""Pytest-only seam for hermetic native-transport unit tests (#695).
|
||||
|
||||
Establishes a **test-mode** process-local record so unit tests can exercise
|
||||
gates that require ``is_native_mcp_transport()``. This record:
|
||||
|
||||
- is rejected outside pytest (including a fresh offline interpreter);
|
||||
- never uses production mode;
|
||||
- cannot authorize production Gitea mutation endpoints
|
||||
(:func:`assert_production_mutation_runtime` / production path of
|
||||
:func:`assert_sanctioned_mutation_runtime` when not under pytest).
|
||||
|
||||
There is no public caller-controlled flag that forges production native
|
||||
transport.
|
||||
"""
|
||||
global _NATIVE_RUNTIME
|
||||
if not is_pytest_runtime():
|
||||
raise UnsanctionedRuntimeError(
|
||||
"install_test_native_runtime rejected: test-mode native runtime "
|
||||
"is only available under pytest (#695). allow_test_bootstrap and "
|
||||
"similar caller-controlled flags do not exist and cannot authorize "
|
||||
"a fresh offline interpreter."
|
||||
)
|
||||
token, fingerprint = _new_runtime_token()
|
||||
_NATIVE_RUNTIME = {
|
||||
"token": token,
|
||||
"token_fingerprint": fingerprint,
|
||||
"pid": os.getpid(),
|
||||
"started_at": time.time(),
|
||||
"entrypoint": "test_bootstrap",
|
||||
"entrypoint_path": None,
|
||||
"phase": _PHASE_TRANSPORT_BOUND,
|
||||
"transport": "test",
|
||||
"mode": _RUNTIME_MODE_TEST,
|
||||
"bound_at": time.time(),
|
||||
}
|
||||
return native_runtime_status()
|
||||
|
||||
|
||||
def clear_native_runtime_for_tests() -> None:
|
||||
"""Test helper: drop native runtime (does not clear env)."""
|
||||
global _NATIVE_RUNTIME
|
||||
_NATIVE_RUNTIME = None
|
||||
|
||||
|
||||
def pinned_session_state_dir() -> str | None:
|
||||
"""Session-state root pinned for this production transport lifetime (#695 AC2).
|
||||
|
||||
When production native transport is bound, durable session proofs must use
|
||||
this directory only. Env overrides of ``GITEA_MCP_SESSION_STATE_DIR`` after
|
||||
bind are ignored so redirected dirs (e.g. ``.mcp_session_701``) cannot
|
||||
manufacture independent decision-lock authority (PR #701 recurrence).
|
||||
"""
|
||||
if not is_production_native_mcp_transport():
|
||||
return None
|
||||
pinned = (_NATIVE_RUNTIME or {}).get("session_state_dir")
|
||||
text = (str(pinned) if pinned is not None else "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def direct_import_env_enabled() -> bool:
|
||||
"""True when the legacy direct-import opt-in env is set (never authorizes)."""
|
||||
return (os.environ.get(ALLOW_DIRECT_IMPORT_ENV) or "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
|
||||
|
||||
def assert_no_direct_import_bypass(context: str = "mutation") -> None:
|
||||
"""Fail closed when GITEA_ALLOW_DIRECT_MCP_IMPORT is used for mutations (#695 AC1).
|
||||
|
||||
The env flag is never a sanctioned recovery path for LLM/agent sessions.
|
||||
Under pytest hermetic tests this is a no-op so unit tests can set the flag
|
||||
to prove it does not grant authority.
|
||||
"""
|
||||
if is_pytest_runtime():
|
||||
return
|
||||
if not direct_import_env_enabled():
|
||||
return
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"{ALLOW_DIRECT_IMPORT_ENV} does not authorize {context} (#695 AC1). "
|
||||
"Direct import of gitea_mcp_server mutation tools is forbidden. "
|
||||
"Stop after native MCP failure; reconnect the official MCP daemon. "
|
||||
"Do not set direct-import flags, offline runners, or redirected "
|
||||
f"{SESSION_STATE_DIR_ENV} directories to reconstruct gates."
|
||||
)
|
||||
|
||||
|
||||
def is_native_mcp_transport() -> bool:
|
||||
"""True when this process holds a transport-bound native runtime (#695)."""
|
||||
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
return False
|
||||
if _NATIVE_RUNTIME is None:
|
||||
return False
|
||||
if int(_NATIVE_RUNTIME.get("pid") or -1) != os.getpid():
|
||||
return False
|
||||
if not (_NATIVE_RUNTIME.get("token") or "").strip():
|
||||
return False
|
||||
if _NATIVE_RUNTIME.get("phase") != _PHASE_TRANSPORT_BOUND:
|
||||
return False
|
||||
if not (_NATIVE_RUNTIME.get("transport") or "").strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_production_native_mcp_transport() -> bool:
|
||||
"""True only for production-mode, transport-bound native runtime."""
|
||||
if not is_native_mcp_transport():
|
||||
return False
|
||||
return (_NATIVE_RUNTIME or {}).get("mode") == _RUNTIME_MODE_PRODUCTION
|
||||
|
||||
|
||||
def is_sanctioned_mcp_daemon() -> bool:
|
||||
"""Backward-compatible name; #695 requires native transport, not env alone."""
|
||||
if is_production_native_mcp_transport():
|
||||
return True
|
||||
if is_pytest_runtime():
|
||||
return True
|
||||
# Explicit direct-import override is for non-LLM operator/test tools only.
|
||||
# It is deliberately ignored when a native runtime is expected for mutations
|
||||
# under LLM sessions (tests use pytest path). Env alone never grants native.
|
||||
return False
|
||||
|
||||
|
||||
def assert_production_mutation_runtime(context: str = "mutation") -> None:
|
||||
"""Fail closed unless production native MCP transport is bound (#695).
|
||||
|
||||
Test-mode bootstrap records and pytest-only hermetic allowances do **not**
|
||||
satisfy this gate. Use for production Gitea mutation endpoints that must
|
||||
never be reachable via test bootstrap.
|
||||
"""
|
||||
if is_production_native_mcp_transport():
|
||||
return
|
||||
mode = (_NATIVE_RUNTIME or {}).get("mode")
|
||||
if mode == _RUNTIME_MODE_TEST:
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"Test-mode native runtime cannot authorize production {context} "
|
||||
"(#695). install_test_native_runtime / former allow_test_bootstrap "
|
||||
"must never reach real Gitea mutation endpoints."
|
||||
)
|
||||
assert_sanctioned_mutation_runtime(context)
|
||||
|
||||
|
||||
def assert_sanctioned_mutation_runtime(context: str = "mutation") -> None:
|
||||
"""Fail closed when mutation code runs outside native MCP transport (#695).
|
||||
|
||||
Under pytest, hermetic unit tests are allowed (profile/permission tests).
|
||||
Outside pytest, requires production-mode transport-bound native runtime.
|
||||
Test-mode records do not authorize non-pytest production mutations.
|
||||
``GITEA_ALLOW_DIRECT_MCP_IMPORT`` never authorizes mutations (#695 AC1).
|
||||
"""
|
||||
if is_pytest_runtime():
|
||||
return
|
||||
# AC1: direct-import env is never a mutation recovery path (PR #701).
|
||||
assert_no_direct_import_bypass(context)
|
||||
if is_production_native_mcp_transport():
|
||||
return
|
||||
mode = (_NATIVE_RUNTIME or {}).get("mode")
|
||||
if mode == _RUNTIME_MODE_TEST:
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"Test-mode native runtime cannot authorize production {context} "
|
||||
"(#695). Test bootstrap cannot reach production mutation endpoints."
|
||||
)
|
||||
env_spoof = (os.environ.get(SANCTIONED_DAEMON_ENV) or "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}
|
||||
extra = ""
|
||||
if env_spoof:
|
||||
extra = (
|
||||
f" Note: {SANCTIONED_DAEMON_ENV} alone is not sufficient (#695); "
|
||||
"native transport requires the official MCP entrypoint and a live "
|
||||
"transport bind."
|
||||
)
|
||||
phase = (_NATIVE_RUNTIME or {}).get("phase")
|
||||
if phase == _PHASE_ENTRYPOINT_CLAIMED:
|
||||
extra = (
|
||||
(extra + " ") if extra else " "
|
||||
) + (
|
||||
"Entrypoint was claimed but native MCP transport was never bound "
|
||||
"(#695); offline launch/import of the real entrypoint does not "
|
||||
"grant mutation authority."
|
||||
)
|
||||
raise UnsanctionedRuntimeError(
|
||||
f"Unsanctioned / non-native runtime blocked {context} (#695). "
|
||||
"Do not import gitea_mcp_server or call mutation helpers from a raw "
|
||||
"shell, offline runner, or ad-hoc script after native MCP failure. "
|
||||
"Stop and reconnect the official MCP daemon (mcp_server.py) over "
|
||||
"native transport. "
|
||||
f"Do not set {ALLOW_DIRECT_IMPORT_ENV}, override "
|
||||
f"{SESSION_STATE_DIR_ENV}, or use raw token env vars in LLM "
|
||||
f"sessions.{extra}"
|
||||
)
|
||||
|
||||
|
||||
def assert_keychain_access_allowed() -> None:
|
||||
"""Fail closed for git-credential keychain fill outside sanctioned contexts."""
|
||||
if is_sanctioned_mcp_daemon():
|
||||
return
|
||||
# Operator-only keychain CLI remains available outside LLM mutation path.
|
||||
if (os.environ.get(ALLOW_KEYCHAIN_CLI_ENV) or "").strip() in {"1", "true", "yes"}:
|
||||
if not is_pytest_runtime():
|
||||
# Still block pure env spoof of SANCTIONED_DAEMON for keychain when
|
||||
# FORCE is set for tests.
|
||||
if (os.environ.get(FORCE_PROVENANCE_FAIL_ENV) or "").strip() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
raise UnsanctionedRuntimeError(
|
||||
"Unsanctioned keychain/credential fill blocked (#558/#695). "
|
||||
"Token extraction via git-credential is only allowed inside the "
|
||||
f"official native MCP daemon or with explicit operator opt-in "
|
||||
f"{ALLOW_KEYCHAIN_CLI_ENV}=1 (never for offline mutation runners)."
|
||||
)
|
||||
|
||||
|
||||
def native_runtime_status() -> dict[str, Any]:
|
||||
"""LLM-safe native runtime status (no raw token)."""
|
||||
rt = _NATIVE_RUNTIME or {}
|
||||
return {
|
||||
"native_mcp_transport": is_native_mcp_transport(),
|
||||
"production_native_mcp_transport": is_production_native_mcp_transport(),
|
||||
"pytest": is_pytest_runtime(),
|
||||
"pid": rt.get("pid"),
|
||||
"token_fingerprint": rt.get("token_fingerprint"),
|
||||
"started_at": rt.get("started_at"),
|
||||
"entrypoint": rt.get("entrypoint"),
|
||||
"entrypoint_path": rt.get("entrypoint_path"),
|
||||
"phase": rt.get("phase"),
|
||||
"transport": rt.get("transport"),
|
||||
"mode": rt.get("mode"),
|
||||
"session_state_dir": pinned_session_state_dir() or rt.get("session_state_dir"),
|
||||
"session_state_dir_pinned": pinned_session_state_dir() is not None,
|
||||
"direct_import_env_set": direct_import_env_enabled(),
|
||||
"env_sanctioned_alone_insufficient": True,
|
||||
"sanctioned_env": SANCTIONED_DAEMON_ENV,
|
||||
"allow_direct_import_env": ALLOW_DIRECT_IMPORT_ENV,
|
||||
"session_state_dir_env": SESSION_STATE_DIR_ENV,
|
||||
"allow_keychain_cli_env": ALLOW_KEYCHAIN_CLI_ENV,
|
||||
}
|
||||
|
||||
|
||||
def runtime_status() -> dict[str, Any]:
|
||||
"""Backward-compatible status payload."""
|
||||
status = native_runtime_status()
|
||||
status["sanctioned_daemon"] = is_sanctioned_mcp_daemon()
|
||||
return status
|
||||
|
||||
|
||||
def mutation_provenance_fields() -> dict[str, Any]:
|
||||
"""Fields to attach to live mutation / review audit records (#695 AC6)."""
|
||||
st = native_runtime_status()
|
||||
transport = "native_mcp" if st["native_mcp_transport"] else "untrusted"
|
||||
if st.get("mode") == _RUNTIME_MODE_TEST and st["native_mcp_transport"]:
|
||||
transport = "test_native_mcp"
|
||||
return {
|
||||
"transport": transport,
|
||||
"native_mcp_transport": bool(st["native_mcp_transport"]),
|
||||
"production_native_mcp_transport": bool(
|
||||
st.get("production_native_mcp_transport")
|
||||
),
|
||||
"native_runtime_pid": st.get("pid"),
|
||||
"native_token_fingerprint": st.get("token_fingerprint"),
|
||||
"entrypoint": st.get("entrypoint"),
|
||||
"phase": st.get("phase"),
|
||||
"mode": st.get("mode"),
|
||||
"session_state_dir": st.get("session_state_dir"),
|
||||
"session_state_dir_pinned": bool(st.get("session_state_dir_pinned")),
|
||||
}
|
||||
Executable
+259
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP Discoverability Validation Tool for external servers (Issue #155)."""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
EXPECTED_JENKINS_TOOLS = {
|
||||
"jenkins_whoami",
|
||||
"jenkins_list_jobs",
|
||||
"jenkins_latest_build",
|
||||
"jenkins_build_status",
|
||||
"jenkins_get_build",
|
||||
}
|
||||
|
||||
EXPECTED_GLITCHTIP_TOOLS = {
|
||||
"glitchtip_whoami",
|
||||
"glitchtip_list_projects",
|
||||
"glitchtip_list_unresolved",
|
||||
"glitchtip_get_issue",
|
||||
"glitchtip_recent_events",
|
||||
"glitchtip_search",
|
||||
}
|
||||
|
||||
RELOAD_INSTRUCTIONS = """
|
||||
=== MCP CLIENT RELOAD/RECONNECT RUNBOOK ===
|
||||
After registering or changing external MCP servers, reload your client to discover the new tools:
|
||||
- Codex: Click 'Reload Developer Tools' or restart the editor.
|
||||
- Gemini / Grok / ChatGPT Desktop: Restart the client or run the reload slash command if available.
|
||||
- Claude Desktop: Use 'Developer -> Reload' or restart the app.
|
||||
- General MCP Clients: Restart the process or reload the server config.
|
||||
===========================================
|
||||
"""
|
||||
|
||||
def parse_gitea_mcp_config(path):
|
||||
if not path or not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
return json.load(fh)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def read_json_rpc_response(proc, req_id):
|
||||
import time
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < 5.0:
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
try:
|
||||
data = json.loads(line)
|
||||
if data.get("id") == req_id:
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
def query_live_tools(command, args, env):
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[command] + args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=run_env,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
tools = []
|
||||
try:
|
||||
# 1. Send initialize
|
||||
init_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "mcp-discoverability-check", "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
proc.stdin.write(json.dumps(init_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Read init response
|
||||
init_resp = read_json_rpc_response(proc, 1)
|
||||
if init_resp:
|
||||
# Send initialized notification
|
||||
init_notif = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/initialized"
|
||||
}
|
||||
proc.stdin.write(json.dumps(init_notif) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# 2. Send tools/list
|
||||
tools_req = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
proc.stdin.write(json.dumps(tools_req) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
tools_resp = read_json_rpc_response(proc, 2)
|
||||
if tools_resp and "result" in tools_resp and "tools" in tools_resp["result"]:
|
||||
for t in tools_resp["result"]["tools"]:
|
||||
tools.append(t["name"])
|
||||
except Exception as e:
|
||||
print(f"Error querying live tools: {e}", file=sys.stderr)
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
|
||||
return set(tools)
|
||||
|
||||
def validate_mcp_client_config(client_config_path, gitea_config_path=None, live=False):
|
||||
if not client_config_path or not os.path.exists(client_config_path):
|
||||
print(f"SKIPPED: MCP client config not found at '{client_config_path}'", file=sys.stderr)
|
||||
return True
|
||||
|
||||
with open(client_config_path, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
config_data = json.load(fh)
|
||||
except Exception as e:
|
||||
print(f"Error parsing client config: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
mcp_servers = config_data.get("mcpServers", {})
|
||||
|
||||
# Check for stale server names
|
||||
stale_names = {"jenkins-readonly", "glitchtip-readonly"}
|
||||
for name in mcp_servers:
|
||||
if name in stale_names:
|
||||
print(f"ERROR: Stale server name '{name}' configured. Use canonical names 'jenkins-mcp' or 'glitchtip-mcp'.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
gitea_data = parse_gitea_mcp_config(gitea_config_path)
|
||||
enabled_services = set()
|
||||
contexts = gitea_data.get("contexts", {})
|
||||
|
||||
profile_name = os.environ.get("GITEA_MCP_PROFILE")
|
||||
if profile_name and "profiles" in gitea_data:
|
||||
profile = gitea_data["profiles"].get(profile_name)
|
||||
if profile and "context" in profile:
|
||||
ctx_name = profile["context"]
|
||||
ctx = contexts.get(ctx_name, {})
|
||||
if ctx.get("enabled"):
|
||||
services = ctx.get("services", {})
|
||||
for s_name, s_data in services.items():
|
||||
if s_data.get("enabled"):
|
||||
enabled_services.add(s_name)
|
||||
else:
|
||||
for ctx_name, ctx in contexts.items():
|
||||
if ctx.get("enabled"):
|
||||
services = ctx.get("services", {})
|
||||
for s_name, s_data in services.items():
|
||||
if s_data.get("enabled"):
|
||||
enabled_services.add(s_name)
|
||||
|
||||
if not enabled_services:
|
||||
print("No external services enabled in Gitea contexts. Discoverability check complete.", file=sys.stderr)
|
||||
return True
|
||||
|
||||
success = True
|
||||
for service in enabled_services:
|
||||
canonical_name = f"{service}-mcp"
|
||||
if canonical_name not in mcp_servers:
|
||||
stale_match = f"{service}-readonly"
|
||||
if stale_match in mcp_servers:
|
||||
print(f"ERROR: Server '{canonical_name}' references stale name '{stale_match}' (fail closed).", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
print(f"ERROR: Enabled service '{service}' is not registered under canonical name '{canonical_name}' in client config.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
server_conf = mcp_servers[canonical_name]
|
||||
command = server_conf.get("command")
|
||||
args = server_conf.get("args") or []
|
||||
env = server_conf.get("env") or {}
|
||||
|
||||
if not command:
|
||||
print(f"ERROR: Server '{canonical_name}' has no command configured.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
expected_module = f"{service}_mcp"
|
||||
if "-m" not in args or expected_module not in args:
|
||||
print(f"ERROR: Server '{canonical_name}' args do not point to expected module '{expected_module}' (args: {args}).", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
profile_var = f"{service.upper()}_MCP_PROFILE"
|
||||
config_var = f"{service.upper()}_MCP_CONFIG"
|
||||
if profile_var not in env or config_var not in env:
|
||||
print(f"ERROR: Server '{canonical_name}' env is missing required variables '{profile_var}' or '{config_var}'.", file=sys.stderr)
|
||||
success = False
|
||||
continue
|
||||
|
||||
if live:
|
||||
tools = query_live_tools(command, args, env)
|
||||
if not tools:
|
||||
print("SKIPPED: server enabled but no usable tools visible", file=sys.stdout)
|
||||
success = False
|
||||
continue
|
||||
|
||||
expected = EXPECTED_JENKINS_TOOLS if service == "jenkins" else EXPECTED_GLITCHTIP_TOOLS
|
||||
missing = expected - tools
|
||||
if missing:
|
||||
print(f"ERROR: Server '{canonical_name}' is missing expected tools: {', '.join(missing)}", file=sys.stderr)
|
||||
success = False
|
||||
else:
|
||||
print(f"SUCCESS: Server '{canonical_name}' discoverability verified.", file=sys.stderr)
|
||||
else:
|
||||
print(f"SUCCESS: Server '{canonical_name}' static registration verified.", file=sys.stderr)
|
||||
|
||||
return success
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MCP client registration discoverability checks.")
|
||||
parser.add_argument("--client-config", help="Path to MCP client config JSON file.")
|
||||
parser.add_argument("--gitea-config", help="Path to Gitea MCP config JSON file.")
|
||||
parser.add_argument("--live", action="store_true", help="Perform live stdio checks on configured servers.")
|
||||
parser.add_argument("--runbook", action="store_true", help="Print reload/reconnect guide runbook instructions.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.runbook:
|
||||
print(RELOAD_INSTRUCTIONS)
|
||||
return 0
|
||||
|
||||
if not args.client_config:
|
||||
print("ERROR: --client-config must be specified.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ok = validate_mcp_client_config(
|
||||
client_config_path=args.client_config,
|
||||
gitea_config_path=args.gitea_config,
|
||||
live=args.live
|
||||
)
|
||||
|
||||
if not ok:
|
||||
print(RELOAD_INSTRUCTIONS, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Assess live MCP namespace health without trusting static registration.
|
||||
|
||||
The IDE/client namespace can fail with EOF even when this Python process still
|
||||
registers the Gitea tools with FastMCP. These helpers keep that distinction
|
||||
explicit so reviewer/merger flows can fail closed on the live path.
|
||||
|
||||
Probe sources
|
||||
-------------
|
||||
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
|
||||
only source that can prove the workflow namespace is healthy).
|
||||
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
|
||||
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
|
||||
IDE-managed namespace is callable.
|
||||
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
REQUIRED_NAMESPACE_TOOLS = {
|
||||
"gitea-author": "gitea_whoami",
|
||||
"gitea-reviewer": "gitea_whoami",
|
||||
"gitea-merger": "gitea_whoami",
|
||||
"gitea-tools": "gitea_list_profiles",
|
||||
}
|
||||
|
||||
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
|
||||
|
||||
# Namespaces that must be healthy for a given mutation task.
|
||||
TASK_REQUIRED_NAMESPACES = {
|
||||
"review_pr": "gitea-reviewer",
|
||||
"submit_review": "gitea-reviewer",
|
||||
"merge_pr": "gitea-merger",
|
||||
}
|
||||
|
||||
PROBE_SOURCE_CLIENT = "client_namespace"
|
||||
PROBE_SOURCE_OFFLINE = "offline_spawn"
|
||||
PROBE_SOURCE_UNKNOWN = "unknown"
|
||||
VALID_PROBE_SOURCES = frozenset(
|
||||
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
|
||||
)
|
||||
|
||||
EOF_PATTERNS = (
|
||||
"client is closing: eof",
|
||||
"transport closed",
|
||||
"connection closed",
|
||||
"broken pipe",
|
||||
"end of file",
|
||||
"eof",
|
||||
)
|
||||
|
||||
SAFE_ENV_KEYS = (
|
||||
"GITEA_MCP_PROFILE",
|
||||
"GITEA_PROFILE_NAME",
|
||||
"GITEA_SERVICE",
|
||||
"GITEA_EXECUTION_ROLE",
|
||||
"GITEA_MCP_CONFIG",
|
||||
)
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(v) for v in value]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _contains_eof(text: str | None) -> bool:
|
||||
lowered = (text or "").lower()
|
||||
return any(pattern in lowered for pattern in EOF_PATTERNS)
|
||||
|
||||
|
||||
def _normalize_probe_source(probe_source: str | None) -> str:
|
||||
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
|
||||
if raw in VALID_PROBE_SOURCES:
|
||||
return raw
|
||||
return PROBE_SOURCE_UNKNOWN
|
||||
|
||||
|
||||
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
|
||||
if not process:
|
||||
return {}
|
||||
env = process.get("env") or process.get("environment") or {}
|
||||
if not isinstance(env, dict):
|
||||
return {}
|
||||
return {
|
||||
key: str(env[key])
|
||||
for key in SAFE_ENV_KEYS
|
||||
if key in env and env[key] not in (None, "")
|
||||
}
|
||||
|
||||
|
||||
def classify_namespace_probe(
|
||||
namespace: str,
|
||||
*,
|
||||
required_tool: str | None = None,
|
||||
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
|
||||
probe_result: dict[str, Any] | None = None,
|
||||
process: dict[str, Any] | None = None,
|
||||
config_path: str | None = None,
|
||||
profile: str | None = None,
|
||||
configured: bool = True,
|
||||
probe_source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify whether a required tool is callable through a live namespace.
|
||||
|
||||
``registered_tools`` is static/server-side evidence. ``probe_result`` is
|
||||
live invocation evidence. Only ``probe_source=client_namespace`` proves the
|
||||
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
|
||||
"""
|
||||
ns = (namespace or "").strip()
|
||||
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
|
||||
source = _normalize_probe_source(probe_source)
|
||||
registered_list = _as_list(registered_tools)
|
||||
registered = None if registered_list is None else tool in registered_list
|
||||
|
||||
probe = probe_result or {}
|
||||
probe_success = bool(probe.get("success"))
|
||||
error_message = str(
|
||||
probe.get("error")
|
||||
or probe.get("message")
|
||||
or probe.get("stderr")
|
||||
or probe.get("exception")
|
||||
or ""
|
||||
)
|
||||
error_type = str(probe.get("error_type") or "").strip()
|
||||
if not error_type and error_message:
|
||||
if _contains_eof(error_message):
|
||||
error_type = "namespace_eof"
|
||||
elif "timeout" in error_message.lower():
|
||||
error_type = "namespace_timeout"
|
||||
else:
|
||||
error_type = "namespace_call_failed"
|
||||
|
||||
if not configured:
|
||||
error_type = "namespace_not_configured"
|
||||
elif registered is False:
|
||||
error_type = "tool_missing"
|
||||
elif not probe_result:
|
||||
error_type = "live_probe_missing"
|
||||
elif not probe_success and not error_type:
|
||||
error_type = "namespace_call_failed"
|
||||
|
||||
callable_live = bool(configured and probe_result and probe_success)
|
||||
# Probe-path health (spawn or client). IDE-proven only for client path.
|
||||
healthy = bool(configured and registered is not False and callable_live)
|
||||
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
|
||||
process_pid = process.get("pid") if isinstance(process, dict) else None
|
||||
profile_name = profile or (
|
||||
process.get("profile") if isinstance(process, dict) else None
|
||||
)
|
||||
env_summary = _safe_env_summary(process)
|
||||
|
||||
reasons: list[str] = []
|
||||
if not configured:
|
||||
reasons.append(f"MCP namespace '{ns}' is not configured.")
|
||||
if registered is False:
|
||||
reasons.append(
|
||||
f"Required tool '{tool}' is not registered in namespace '{ns}'."
|
||||
)
|
||||
if error_type == "live_probe_missing":
|
||||
reasons.append(
|
||||
f"No live client invocation proof was supplied for '{ns}.{tool}'."
|
||||
)
|
||||
elif error_type == "namespace_eof":
|
||||
reasons.append(
|
||||
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
|
||||
)
|
||||
elif error_type == "namespace_timeout":
|
||||
reasons.append(
|
||||
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
|
||||
)
|
||||
elif error_type == "namespace_call_failed":
|
||||
reasons.append(
|
||||
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
|
||||
)
|
||||
if source == PROBE_SOURCE_OFFLINE:
|
||||
reasons.append(
|
||||
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
|
||||
"not prove the IDE-managed MCP namespace is healthy."
|
||||
)
|
||||
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
|
||||
reasons.append(
|
||||
"Probe source unspecified; treat as not IDE-namespace proof unless "
|
||||
"re-supplied with probe_source='client_namespace'."
|
||||
)
|
||||
|
||||
remediation = []
|
||||
if not healthy or not ide_namespace_proven:
|
||||
remediation.append(
|
||||
"Reconnect the IDE MCP client namespace (client reconnect / "
|
||||
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
|
||||
"record the result with probe_source='client_namespace'."
|
||||
)
|
||||
remediation.append(
|
||||
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
|
||||
"shell kill/PID respawn as proof the IDE namespace is repaired."
|
||||
)
|
||||
if process_pid and source != PROBE_SOURCE_OFFLINE:
|
||||
remediation.append(
|
||||
f"Diagnostics may include PID {process_pid}; process details "
|
||||
"are informational only — recovery is client-layer reconnect."
|
||||
)
|
||||
else:
|
||||
remediation.append(
|
||||
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
|
||||
f"(probe_source={source})."
|
||||
)
|
||||
|
||||
# Client-namespace broken health blocks review/merge. Offline probes never
|
||||
# authorize mutations and only block when they report unhealthy (still
|
||||
# fail-closed for known bad spawn evidence).
|
||||
blocks = False
|
||||
if source == PROBE_SOURCE_CLIENT:
|
||||
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||
elif source == PROBE_SOURCE_OFFLINE:
|
||||
# Offline never proves IDE health; never unblock. Unhealthy offline
|
||||
# still surfaces as a soft diagnostic, not a mutation-ledger block.
|
||||
blocks = False
|
||||
else:
|
||||
# Unknown source: only block when evidence is unhealthy (fail closed
|
||||
# on bad data without treating success as IDE proof).
|
||||
blocks = namespace_health_blocks_task("merge_pr", healthy)
|
||||
|
||||
return {
|
||||
"success": healthy,
|
||||
"healthy": healthy,
|
||||
"namespace": ns,
|
||||
"required_tool": tool,
|
||||
"configured": configured,
|
||||
"registered_tools_checked": registered_list is not None,
|
||||
"required_tool_registered": registered,
|
||||
"required_tool_callable": callable_live,
|
||||
"probe_source": source,
|
||||
"ide_namespace_proven": ide_namespace_proven,
|
||||
"error_type": None if healthy else error_type,
|
||||
"error_message": error_message or None,
|
||||
"reasons": reasons,
|
||||
"remediation": remediation,
|
||||
"diagnostics": {
|
||||
"namespace": ns,
|
||||
"required_tool": tool,
|
||||
"process_pid": process_pid,
|
||||
"profile": profile_name,
|
||||
"env": env_summary,
|
||||
"config_path": config_path,
|
||||
"probe_source": source,
|
||||
},
|
||||
"blocks_merge_workflow": blocks,
|
||||
}
|
||||
|
||||
|
||||
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
|
||||
"""Return whether a broken namespace must block a workflow task."""
|
||||
if healthy:
|
||||
return False
|
||||
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
|
||||
|
||||
|
||||
def required_namespace_for_task(task: str) -> str | None:
|
||||
"""Map a mutation task to the MCP namespace that must be healthy."""
|
||||
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
|
||||
|
||||
|
||||
def mutation_gate_from_session(
|
||||
task: str,
|
||||
session_health: dict[str, dict[str, Any]] | None,
|
||||
) -> list[str]:
|
||||
"""Fail-closed gate using recorded client-namespace health assessments.
|
||||
|
||||
* Missing session entry → no gate (caller has not assessed yet).
|
||||
* Client-namespace unhealthy / not IDE-proven → block mutation.
|
||||
* Offline-only session entries never authorize mutations.
|
||||
"""
|
||||
ns = required_namespace_for_task(task)
|
||||
if not ns:
|
||||
return []
|
||||
store = session_health or {}
|
||||
entry = store.get(ns)
|
||||
if not entry:
|
||||
return []
|
||||
source = _normalize_probe_source(entry.get("probe_source"))
|
||||
if source != PROBE_SOURCE_CLIENT:
|
||||
return [
|
||||
f"recorded namespace health for '{ns}' is probe_source={source}, "
|
||||
"not client_namespace; re-probe through the IDE-managed path "
|
||||
f"before {(task or 'mutation')}"
|
||||
]
|
||||
if entry.get("ide_namespace_proven") and entry.get("healthy"):
|
||||
return []
|
||||
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
|
||||
detail = entry.get("error_type") or "unhealthy"
|
||||
return [
|
||||
f"live MCP namespace '{ns}' is recorded {detail} "
|
||||
f"(probe_source={source}); repair the IDE namespace before "
|
||||
f"{(task or 'mutation')} (fail closed, #543)"
|
||||
]
|
||||
if not entry.get("ide_namespace_proven"):
|
||||
return [
|
||||
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
|
||||
"client_namespace probe before mutation (fail closed, #543)"
|
||||
]
|
||||
return []
|
||||
@@ -0,0 +1,360 @@
|
||||
"""MCP-native post-merge cleanup proof verifier (#517).
|
||||
|
||||
Post-merge cleanup of leases, comments, branches, and worktrees must be
|
||||
proven through explicit MCP tools (or approved helpers), never raw scripts or
|
||||
ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a
|
||||
reconciler profile with the right capability proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
AUTHORIZED_CLEANUP_TOOLS = frozenset({
|
||||
"gitea_cleanup_post_merge_moot_lease",
|
||||
"gitea_cleanup_obsolete_reviewer_comment_lease",
|
||||
"gitea_reconcile_merged_cleanups",
|
||||
"gitea_delete_branch",
|
||||
"gitea_cleanup_merged_pr_branch",
|
||||
"gitea_audit_worktree_cleanup",
|
||||
"gitea_capture_branches_worktree_snapshot",
|
||||
"gitea_assess_worktree_cleanup_integrity",
|
||||
"gitea_authorize_reconciliation_cleanup_phase",
|
||||
})
|
||||
|
||||
_RAW_BRANCH_DELETE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I),
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I),
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I),
|
||||
)
|
||||
|
||||
_RAW_COMMENT_DELETE_PATTERNS = (
|
||||
re.compile(
|
||||
r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*"
|
||||
r"(?:/comments/|issues/\d+/comments)",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b",
|
||||
re.I,
|
||||
),
|
||||
re.compile(
|
||||
r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete",
|
||||
re.I,
|
||||
),
|
||||
)
|
||||
|
||||
_CLEANUP_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_GIT_REF_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_WORKTREE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_MUTATIONS_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"})
|
||||
_MUTATION_LEDGER_PATTERNS = (
|
||||
_CLEANUP_MUTATIONS_RE,
|
||||
_GIT_REF_MUTATIONS_RE,
|
||||
_WORKTREE_MUTATIONS_RE,
|
||||
)
|
||||
_RAW_WORKTREE_REMOVE_PATTERNS = (
|
||||
re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I),
|
||||
)
|
||||
_AUTHORIZED_TOOL_RE = re.compile(
|
||||
r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RECONCILER_CAPABILITY_RE = re.compile(
|
||||
r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGER_CLEANUP_ROLE_RE = re.compile(
|
||||
r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}"
|
||||
r"(?:branch|worktree|comment|lease)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_HANDOFF_TO_RECONCILER_RE = re.compile(
|
||||
r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None:
|
||||
match = pattern.search(text)
|
||||
if not match:
|
||||
return None
|
||||
return (match.group(1) or "").strip()
|
||||
|
||||
|
||||
def _is_none_ledger_value(value: str) -> bool:
|
||||
return value.strip().lower() in _NONE_LEDGER_VALUES
|
||||
|
||||
|
||||
def scoped_cleanup_mutation_ledger_text(text: str | None) -> str:
|
||||
"""Return mutation-ledger bodies scoped to cleanup enforcement (#517)."""
|
||||
text = text or ""
|
||||
parts: list[str] = []
|
||||
for pattern in _MUTATION_LEDGER_PATTERNS:
|
||||
body = _ledger_field_body(text, pattern)
|
||||
if body is not None and not _is_none_ledger_value(body):
|
||||
parts.append(body)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _git_ref_cleanup_claimed(body: str) -> bool:
|
||||
return bool(raw_branch_delete_commands(body))
|
||||
|
||||
|
||||
def _worktree_cleanup_claimed(body: str) -> bool:
|
||||
for pattern in _RAW_WORKTREE_REMOVE_PATTERNS:
|
||||
if pattern.search(body):
|
||||
return True
|
||||
return bool(
|
||||
re.search(
|
||||
r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b",
|
||||
body,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]:
|
||||
claiming: list[tuple[str, str]] = []
|
||||
cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE)
|
||||
if cleanup_body is not None and not _is_none_ledger_value(cleanup_body):
|
||||
claiming.append(("Cleanup mutations", cleanup_body))
|
||||
|
||||
git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE)
|
||||
if git_ref_body is not None and not _is_none_ledger_value(git_ref_body):
|
||||
if _git_ref_cleanup_claimed(git_ref_body):
|
||||
claiming.append(("Git ref mutations", git_ref_body))
|
||||
|
||||
worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE)
|
||||
if worktree_body is not None and not _is_none_ledger_value(worktree_body):
|
||||
if _worktree_cleanup_claimed(worktree_body):
|
||||
claiming.append(("Worktree mutations", worktree_body))
|
||||
return claiming
|
||||
|
||||
|
||||
def raw_branch_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw git branch-delete commands cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_BRANCH_DELETE_PATTERNS:
|
||||
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||
return list(dict.fromkeys(commands))
|
||||
|
||||
|
||||
def raw_comment_delete_commands(text: str | None) -> list[str]:
|
||||
"""Return raw comment-deletion commands/scripts cited in *text*."""
|
||||
if not text:
|
||||
return []
|
||||
commands: list[str] = []
|
||||
for pattern in _RAW_COMMENT_DELETE_PATTERNS:
|
||||
commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text))
|
||||
return list(dict.fromkeys(commands))
|
||||
|
||||
|
||||
def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when mutation ledgers cite raw git branch deletion."""
|
||||
commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
||||
reasons = [
|
||||
(
|
||||
"raw git branch deletion bypasses MCP branch.delete cleanup gates: "
|
||||
f"{command}"
|
||||
)
|
||||
for command in commands
|
||||
]
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"commands": commands,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use gitea_delete_branch, gitea_reconcile_merged_cleanups, or another "
|
||||
"approved MCP cleanup helper with explicit branch.delete capability"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]:
|
||||
"""Fail closed when mutation ledgers cite raw comment deletion."""
|
||||
commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text))
|
||||
reasons = [
|
||||
(
|
||||
"raw comment deletion bypasses MCP lease cleanup gates: "
|
||||
f"{command}"
|
||||
)
|
||||
for command in commands
|
||||
]
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"commands": commands,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"use gitea_cleanup_post_merge_moot_lease (append-only release comment) "
|
||||
"or hand cleanup to a reconciler session; never delete lease comments"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]:
|
||||
"""Require merge mutations and cleanup mutations in separate ledger fields."""
|
||||
text = text or ""
|
||||
merge_match = _MERGE_MUTATIONS_RE.search(text)
|
||||
cleanup_match = _CLEANUP_MUTATIONS_RE.search(text)
|
||||
reasons: list[str] = []
|
||||
|
||||
if cleanup_match and not merge_match:
|
||||
combined = re.search(
|
||||
r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if combined:
|
||||
reasons.append(
|
||||
"merge and cleanup mutations must use separate 'Merge mutations' "
|
||||
"and 'Cleanup mutations' ledger fields"
|
||||
)
|
||||
|
||||
if merge_match and cleanup_match:
|
||||
merge_val = (merge_match.group(1) or "").strip().lower()
|
||||
cleanup_val = (cleanup_match.group(1) or "").strip().lower()
|
||||
if merge_val == cleanup_val and merge_val not in {"", "none"}:
|
||||
reasons.append(
|
||||
"merge mutations and cleanup mutations must not duplicate the "
|
||||
"same ledger entry"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"split merge mutations (gitea_merge_pr) from cleanup mutations "
|
||||
"(reconciler MCP tools) in the controller handoff"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]:
|
||||
"""Validate cleanup claims cite authorized MCP tools and reconciler capability."""
|
||||
text = text or ""
|
||||
claiming = _cleanup_claiming_ledger_fields(text)
|
||||
if not claiming:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
reasons: list[str] = []
|
||||
for field_name, body in claiming:
|
||||
if not _AUTHORIZED_TOOL_RE.search(body):
|
||||
reasons.append(
|
||||
f"{field_name} cleanup must name an authorized MCP cleanup tool "
|
||||
f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})"
|
||||
)
|
||||
if not _RECONCILER_CAPABILITY_RE.search(text):
|
||||
reasons.append(
|
||||
"post-merge cleanup requires reconciler capability proof "
|
||||
"(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)"
|
||||
)
|
||||
|
||||
return {
|
||||
"proven": not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"hand cleanup to a prgs-reconciler session and cite the exact MCP tool "
|
||||
"plus delete_branch/reconcile_merged_cleanups capability proof"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]:
|
||||
"""Merger sessions that performed cleanup must hand off to reconciler."""
|
||||
text = text or ""
|
||||
if not _MERGER_CLEANUP_ROLE_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
if _HANDOFF_TO_RECONCILER_RE.search(text):
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"merger/reviewer session performed cleanup but did not hand off to "
|
||||
"reconciler for MCP-native cleanup"
|
||||
],
|
||||
"safe_next_action": (
|
||||
"merger sessions must end with cleanup handed to prgs-reconciler; "
|
||||
"do not perform ad hoc branch/comment/worktree cleanup as merger"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]:
|
||||
"""Composite #517 verifier for MCP-native post-merge cleanup proof."""
|
||||
text = report_text or ""
|
||||
checks = (
|
||||
assess_raw_branch_delete_report(text),
|
||||
assess_raw_comment_delete_report(text),
|
||||
assess_merge_cleanup_mutation_separation(text),
|
||||
assess_authorized_reconciler_cleanup_path(text),
|
||||
assess_merger_cleanup_handoff_guidance(text),
|
||||
)
|
||||
reasons: list[str] = []
|
||||
safe_next = "proceed"
|
||||
for result in checks:
|
||||
reasons.extend(result.get("reasons") or [])
|
||||
if result.get("block") and result.get("safe_next_action"):
|
||||
safe_next = result["safe_next_action"]
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"proven": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"raw_branch_commands": raw_branch_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
"raw_comment_commands": raw_comment_delete_commands(
|
||||
scoped_cleanup_mutation_ledger_text(text)
|
||||
),
|
||||
}
|
||||
+55
-4076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,449 @@
|
||||
"""Durable MCP session validation state shared across daemon process pools (#559).
|
||||
|
||||
The IDE often routes sequential MCP tool calls to different daemon processes.
|
||||
Session-scoped proofs (workflow load, review decision lock) must therefore
|
||||
survive process boundaries while remaining fail-closed against spoofing.
|
||||
|
||||
Security notes (extends #211):
|
||||
- Never store under host-global ``/tmp`` (world-writable, spoofable).
|
||||
- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``).
|
||||
- Files are written atomically with mode ``0o600``.
|
||||
- Records are keyed by remote + org + repo + profile identity, not by PID.
|
||||
- TTL prevents indefinitely stale reuse across unrelated sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR"
|
||||
DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state")
|
||||
TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS"
|
||||
DEFAULT_TTL_HOURS = 4.0
|
||||
|
||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||
KIND_DECISION_LOCK = "review_decision_lock"
|
||||
KIND_REVIEW_DRAFT = "review_draft"
|
||||
# Durable marker set when a worker session attempts a direct stable-branch push
|
||||
# or a root-checkout local commit (#671). Keyed per profile identity like the
|
||||
# other session proofs; a contaminated session fails closed on gated mutations
|
||||
# until a reconciler audits and clears it.
|
||||
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||
|
||||
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
|
||||
SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK"
|
||||
|
||||
|
||||
def default_state_dir() -> str:
|
||||
"""Resolve the durable session-state root.
|
||||
|
||||
When production native MCP transport is bound (#695 AC2), the directory
|
||||
pinned at transport bind is authoritative: later overrides of
|
||||
``GITEA_MCP_SESSION_STATE_DIR`` cannot manufacture a second authority
|
||||
domain (PR #701 recurrence: ``.mcp_session_701`` evasion of cross-PR
|
||||
decision locks).
|
||||
"""
|
||||
try:
|
||||
import mcp_daemon_guard
|
||||
|
||||
pinned = mcp_daemon_guard.pinned_session_state_dir()
|
||||
if pinned:
|
||||
return pinned
|
||||
except Exception:
|
||||
# Fail open to env/default only when guard is unavailable (e.g. partial
|
||||
# import during bootstrap). Mutation gates still fail closed separately.
|
||||
pass
|
||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def env_session_state_dir_unpinned() -> str:
|
||||
"""Raw env/default session-state dir ignoring production transport pin.
|
||||
|
||||
Intended for diagnostics and tests that assert pin behavior — not for
|
||||
mutation-sensitive durable proofs under a bound native daemon.
|
||||
"""
|
||||
raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip()
|
||||
return raw or DEFAULT_STATE_DIR
|
||||
|
||||
|
||||
def ttl_hours() -> float:
|
||||
raw = (os.environ.get(TTL_HOURS_ENV) or "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TTL_HOURS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_TTL_HOURS
|
||||
return value if value > 0 else DEFAULT_TTL_HOURS
|
||||
|
||||
|
||||
def _sanitize_segment(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return "_"
|
||||
return _SAFE_SEGMENT_RE.sub("_", text)
|
||||
|
||||
|
||||
def current_profile_identity(
|
||||
profile_name: str | None = None,
|
||||
session_profile_lock: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the session profile identity used as the durable key."""
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
explicit = (profile_identity or session_profile_lock or "").strip()
|
||||
lock = (explicit or env_lock or "").strip()
|
||||
name = (profile_name or "").strip()
|
||||
return lock or name or "unknown-profile"
|
||||
|
||||
|
||||
def state_key(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> str:
|
||||
"""Build durable filename key.
|
||||
|
||||
Session proofs are one-active-per-profile (workflow load / decision lock),
|
||||
so the primary key is kind + profile identity. Remote/org/repo are stored
|
||||
inside the payload and validated on load (#559), which lets a later daemon
|
||||
process recover state without already knowing the remote argument.
|
||||
"""
|
||||
# Keep remote/org/repo parameters for API stability / future kinds; they are
|
||||
# intentionally not part of the filename for session-scoped proofs.
|
||||
_ = (remote, org, repo)
|
||||
return "-".join(
|
||||
_sanitize_segment(part)
|
||||
for part in (
|
||||
kind,
|
||||
profile_identity or "unknown-profile",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def state_file_path(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
name = state_key(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
)
|
||||
return os.path.join(root, f"{name}.json")
|
||||
|
||||
|
||||
def _ensure_state_dir(state_dir: str | None = None) -> str:
|
||||
root = (state_dir or default_state_dir()).strip()
|
||||
os.makedirs(root, mode=0o700, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_iso(value: str | None) -> datetime | None:
|
||||
text = (value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_path: str):
|
||||
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield fd
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _read_json(path: str) -> dict[str, Any] | None:
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _write_json(path: str, data: dict[str, Any]) -> None:
|
||||
parent = os.path.dirname(path) or "."
|
||||
os.makedirs(parent, mode=0o700, exist_ok=True)
|
||||
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
|
||||
fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_path, 0o600)
|
||||
os.replace(temp_path, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def identity_match_reasons(
|
||||
record: dict[str, Any] | None,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Return fail-closed reasons when durable record identity does not match."""
|
||||
if record is None:
|
||||
return []
|
||||
reasons: list[str] = []
|
||||
expected_profile = current_profile_identity(profile_identity=profile_identity)
|
||||
stored_profile = (
|
||||
(record.get("session_profile_lock") or record.get("profile_identity") or "")
|
||||
.strip()
|
||||
)
|
||||
if stored_profile and expected_profile and stored_profile != expected_profile:
|
||||
if expected_profile != "unknown-profile":
|
||||
reasons.append(
|
||||
"session state profile identity mismatch "
|
||||
f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)"
|
||||
)
|
||||
|
||||
for field, expected in (
|
||||
("remote", remote),
|
||||
("org", org),
|
||||
("repo", repo),
|
||||
):
|
||||
want = (expected or "").strip()
|
||||
have = (str(record.get(field) or "")).strip()
|
||||
if want and have and want != have:
|
||||
reasons.append(
|
||||
f"session state {field} mismatch "
|
||||
f"(stored={have!r}, expected={want!r}; fail closed)"
|
||||
)
|
||||
|
||||
recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at"))
|
||||
if recorded_at is None:
|
||||
reasons.append("session state missing recorded_at timestamp (fail closed)")
|
||||
else:
|
||||
age = _now_utc() - recorded_at
|
||||
if age > timedelta(hours=ttl_hours()):
|
||||
reasons.append(
|
||||
f"session state expired after {ttl_hours():g}h (fail closed)"
|
||||
)
|
||||
if age < timedelta(0):
|
||||
reasons.append("session state recorded_at is in the future (fail closed)")
|
||||
return reasons
|
||||
|
||||
|
||||
def load_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Load durable state payload when identity checks pass."""
|
||||
profile = current_profile_identity(profile_identity=profile_identity)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
envelope = _read_json(path)
|
||||
if not envelope:
|
||||
return None
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
# Identity fields live on both envelope and payload for convenience.
|
||||
merged = dict(payload)
|
||||
for key in (
|
||||
"kind",
|
||||
"remote",
|
||||
"org",
|
||||
"repo",
|
||||
"profile_identity",
|
||||
"session_profile_lock",
|
||||
"recorded_at",
|
||||
"updated_at",
|
||||
"writer_pid",
|
||||
):
|
||||
if key in envelope and key not in merged:
|
||||
merged[key] = envelope[key]
|
||||
reasons = identity_match_reasons(
|
||||
merged,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile,
|
||||
)
|
||||
if reasons:
|
||||
return None
|
||||
return merged
|
||||
|
||||
|
||||
def save_state(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any] | None,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Persist or clear durable state for the given session identity key."""
|
||||
profile = current_profile_identity(
|
||||
profile_name=payload.get("session_profile") if payload else None,
|
||||
session_profile_lock=(
|
||||
(payload or {}).get("session_profile_lock") or profile_identity
|
||||
),
|
||||
)
|
||||
# Prefer explicit args over payload fields for key location.
|
||||
key_remote = remote if remote is not None else (payload or {}).get("remote")
|
||||
key_org = org if org is not None else (payload or {}).get("org")
|
||||
key_repo = repo if repo is not None else (payload or {}).get("repo")
|
||||
|
||||
root = _ensure_state_dir(state_dir)
|
||||
path = state_file_path(
|
||||
kind=kind,
|
||||
remote=key_remote,
|
||||
org=key_org,
|
||||
repo=key_repo,
|
||||
profile_identity=profile,
|
||||
state_dir=root,
|
||||
)
|
||||
lock_path = f"{path}.lock"
|
||||
with _exclusive_file_lock(lock_path):
|
||||
if payload is None:
|
||||
for candidate in (path, lock_path):
|
||||
if os.path.exists(candidate):
|
||||
try:
|
||||
os.remove(candidate)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
now = _now_utc().isoformat().replace("+00:00", "Z")
|
||||
body = dict(payload)
|
||||
body.setdefault("session_pid", os.getpid())
|
||||
body["writer_pid"] = os.getpid()
|
||||
body["profile_identity"] = profile
|
||||
if not (body.get("session_profile_lock") or "").strip():
|
||||
body["session_profile_lock"] = profile
|
||||
body["recorded_at"] = body.get("recorded_at") or now
|
||||
body["updated_at"] = now
|
||||
if key_remote is not None:
|
||||
body["remote"] = key_remote
|
||||
if key_org is not None:
|
||||
body["org"] = key_org
|
||||
if key_repo is not None:
|
||||
body["repo"] = key_repo
|
||||
# Stamp session-state authority used for this write (#695 AC2 / AC6).
|
||||
body.setdefault("session_state_dir", root)
|
||||
try:
|
||||
import mcp_daemon_guard
|
||||
|
||||
prov = mcp_daemon_guard.mutation_provenance_fields()
|
||||
body.setdefault(
|
||||
"native_token_fingerprint", prov.get("native_token_fingerprint")
|
||||
)
|
||||
body.setdefault(
|
||||
"native_mcp_transport", bool(prov.get("native_mcp_transport"))
|
||||
)
|
||||
body.setdefault(
|
||||
"production_native_mcp_transport",
|
||||
bool(prov.get("production_native_mcp_transport")),
|
||||
)
|
||||
body.setdefault("transport", prov.get("transport"))
|
||||
except Exception:
|
||||
body.setdefault("native_mcp_transport", False)
|
||||
body.setdefault("transport", "untrusted")
|
||||
|
||||
envelope = {
|
||||
"kind": kind,
|
||||
"remote": key_remote,
|
||||
"org": key_org,
|
||||
"repo": key_repo,
|
||||
"profile_identity": profile,
|
||||
"session_profile_lock": body.get("session_profile_lock"),
|
||||
"recorded_at": body["recorded_at"],
|
||||
"updated_at": body["updated_at"],
|
||||
"writer_pid": body["writer_pid"],
|
||||
"session_state_dir": body.get("session_state_dir"),
|
||||
"transport": body.get("transport"),
|
||||
"payload": body,
|
||||
}
|
||||
_write_json(path, envelope)
|
||||
return dict(body)
|
||||
|
||||
|
||||
def clear_state(
|
||||
*,
|
||||
kind: str,
|
||||
remote: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
state_dir: str | None = None,
|
||||
) -> None:
|
||||
save_state(
|
||||
kind=kind,
|
||||
payload=None,
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
profile_identity=profile_identity,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Merge approval must pin the current PR head SHA (#471 / #695).
|
||||
|
||||
Formal APPROVED reviews that predate the live PR head must not satisfy
|
||||
``gitea_merge_pr`` eligibility. Contaminated / quarantined approvals (#695)
|
||||
must not authorize merge either. Pure assessment helpers are isolated here
|
||||
for hermetic unit tests apart from MCP HTTP calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def assess_merge_approval_head(
|
||||
*,
|
||||
current_head_sha: str | None,
|
||||
latest_by_reviewer: dict,
|
||||
quarantined_review_ids: set | None = None,
|
||||
) -> dict:
|
||||
"""Return whether a visible approval applies to the live PR head.
|
||||
|
||||
Args:
|
||||
current_head_sha: Current PR head commit SHA.
|
||||
latest_by_reviewer: Map of reviewer login → review entry dicts with
|
||||
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
|
||||
Optional ``review_id`` / ``id`` used for quarantine checks (#695).
|
||||
quarantined_review_ids: Optional set of review IDs that must not count
|
||||
toward merge authorization (#695).
|
||||
|
||||
Returns:
|
||||
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
|
||||
and ``stale_approval_block_reason`` (set when merge must fail closed).
|
||||
"""
|
||||
current = (current_head_sha or "").strip()
|
||||
blocked_ids = {int(x) for x in (quarantined_review_ids or set()) if x is not None}
|
||||
|
||||
def _rid(entry: dict):
|
||||
raw = entry.get("review_id", entry.get("id"))
|
||||
try:
|
||||
return int(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
approved_entries = []
|
||||
quarantined_at_head = []
|
||||
for entry in (latest_by_reviewer or {}).values():
|
||||
if (entry.get("verdict") or "").upper() != "APPROVED":
|
||||
continue
|
||||
if entry.get("dismissed"):
|
||||
continue
|
||||
rid = _rid(entry)
|
||||
head = (entry.get("reviewed_head_sha") or "").strip()
|
||||
if rid is not None and rid in blocked_ids:
|
||||
if current and head == current:
|
||||
quarantined_at_head.append(entry)
|
||||
continue
|
||||
if entry.get("quarantined"):
|
||||
if current and head == current:
|
||||
quarantined_at_head.append(entry)
|
||||
continue
|
||||
approved_entries.append(entry)
|
||||
|
||||
at_current = any(
|
||||
(entry.get("reviewed_head_sha") or "").strip() == current
|
||||
for entry in approved_entries
|
||||
if current
|
||||
)
|
||||
latest_approved = None
|
||||
if approved_entries:
|
||||
latest_entry = sorted(
|
||||
approved_entries,
|
||||
key=lambda entry: (
|
||||
entry.get("submitted_at") or "",
|
||||
entry.get("reviewed_head_sha") or "",
|
||||
),
|
||||
)[-1]
|
||||
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
|
||||
reason = None
|
||||
if quarantined_at_head and not at_current:
|
||||
reason = (
|
||||
"contaminated/quarantined approval at current head is void for "
|
||||
"merge authorization (#695); required next action: fresh native "
|
||||
"MCP re-review after controller quarantine evidence is recorded"
|
||||
)
|
||||
elif approved_entries and not at_current:
|
||||
reason = (
|
||||
f"stale approval: approved SHA '{latest_approved}' does not match "
|
||||
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
|
||||
"required next action: re-review PR at current head before merge"
|
||||
)
|
||||
|
||||
return {
|
||||
"approval_at_current_head": at_current,
|
||||
"latest_approved_head_sha": latest_approved,
|
||||
"stale_approval_block_reason": reason,
|
||||
"quarantined_approvals_at_current_head": len(quarantined_at_head),
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
"""Merged PR branch and worktree cleanup reconciliation (#269).
|
||||
|
||||
Builds dry-run reports for merged pull requests and optionally executes
|
||||
remote branch deletion and local worktree removal when every safety gate passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_worktree import parse_dirty_tracked_files
|
||||
import issue_lock_store
|
||||
|
||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
|
||||
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
|
||||
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
|
||||
REVIEWER_SCRATCH_NAME_RE = re.compile(
|
||||
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def extract_linked_issue(title: str, body: str) -> int | None:
|
||||
"""Return the first Closes/Fixes issue number from PR metadata."""
|
||||
for text in (title or "", body or ""):
|
||||
match = CLOSES_FIXES_RE.search(text)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def branch_worktree_folder(branch: str) -> str:
|
||||
return (branch or "").replace("/", "-")
|
||||
|
||||
|
||||
def resolve_worktree_path(project_root: str, branch: str) -> str:
|
||||
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
|
||||
|
||||
|
||||
def extract_issue_marker(*values: str | None) -> int | None:
|
||||
"""Return the first issue marker found in branch/path-like values."""
|
||||
for value in values:
|
||||
match = ISSUE_MARKER_RE.search(value or "")
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured entries."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal current
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = None
|
||||
|
||||
for raw_line in (res.stdout or "").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
flush()
|
||||
current = {"path": line[9:].strip()}
|
||||
elif current is not None and line.startswith("HEAD "):
|
||||
current["head_sha"] = line[5:].strip()
|
||||
elif current is not None and line.startswith("branch "):
|
||||
ref = line[7:].strip()
|
||||
current["branch_ref"] = ref
|
||||
current["branch"] = (
|
||||
ref[len("refs/heads/"):]
|
||||
if ref.startswith("refs/heads/")
|
||||
else ref
|
||||
)
|
||||
elif current is not None and line == "detached":
|
||||
current["detached"] = True
|
||||
flush()
|
||||
return entries
|
||||
|
||||
|
||||
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
|
||||
"""Parse ``git worktree list --porcelain`` into structured records."""
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "list", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
records: list[dict[str, str | None]] = []
|
||||
current: dict[str, str | None] = {}
|
||||
for line in res.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {}
|
||||
continue
|
||||
if line.startswith("worktree "):
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
current = {"worktree": line[9:].strip(), "branch": None, "head": None}
|
||||
elif line.startswith("branch ") and current:
|
||||
ref = line[7:].strip()
|
||||
current["branch"] = (
|
||||
ref[11:] if ref.startswith("refs/heads/") else ref
|
||||
)
|
||||
elif line.startswith("HEAD ") and current:
|
||||
current["head"] = line[5:].strip()
|
||||
elif line == "detached" and current:
|
||||
current["branch"] = None
|
||||
if current.get("worktree"):
|
||||
records.append(current)
|
||||
return records
|
||||
|
||||
|
||||
def discover_local_worktrees(project_root: str) -> dict[str, str]:
|
||||
"""Return a mapping from branch name to absolute worktree path (#528)."""
|
||||
mapping: dict[str, str] = {}
|
||||
for entry in list_local_worktrees(project_root):
|
||||
branch = entry.get("branch")
|
||||
path = entry.get("path")
|
||||
if branch and path:
|
||||
mapping[str(branch)] = str(path)
|
||||
return mapping
|
||||
|
||||
|
||||
def _is_under_branches(project_root: str, path: str) -> bool:
|
||||
branches_root = os.path.realpath(os.path.join(project_root, "branches"))
|
||||
real_path = os.path.realpath(path)
|
||||
return real_path == branches_root or real_path.startswith(branches_root + os.sep)
|
||||
|
||||
|
||||
def _head_is_safe_for_cleanup(
|
||||
*,
|
||||
project_root: str,
|
||||
candidate_head: str | None,
|
||||
pr_head_sha: str | None,
|
||||
target_ref: str | None,
|
||||
) -> bool:
|
||||
if not candidate_head:
|
||||
return False
|
||||
if pr_head_sha and candidate_head == pr_head_sha:
|
||||
return True
|
||||
if target_ref:
|
||||
return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_cleanup_worktree_state(
|
||||
*,
|
||||
project_root: str,
|
||||
head_branch: str,
|
||||
issue_number: int | None,
|
||||
pr_head_sha: str | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Find the exact or safe alias worktree used for local cleanup (#532)."""
|
||||
expected_path = resolve_worktree_path(project_root, head_branch)
|
||||
state = read_local_worktree_state(expected_path)
|
||||
state["worktree_path"] = expected_path
|
||||
state["expected_worktree_path"] = expected_path
|
||||
state["discovered_worktree_path"] = expected_path if state.get("exists") else None
|
||||
state["match_type"] = "derived_path" if state.get("exists") else "none"
|
||||
state["candidate_paths"] = []
|
||||
state["alias_block_reasons"] = []
|
||||
if state.get("exists"):
|
||||
return state
|
||||
|
||||
branch_issue = extract_issue_marker(head_branch)
|
||||
wanted_issue = issue_number or branch_issue
|
||||
candidates: list[dict[str, Any]] = []
|
||||
unsafe_matches: list[str] = []
|
||||
|
||||
for entry in list_local_worktrees(project_root):
|
||||
path = entry.get("path") or ""
|
||||
if not path or not _is_under_branches(project_root, path):
|
||||
continue
|
||||
branch = entry.get("branch") or ""
|
||||
path_issue = extract_issue_marker(os.path.basename(path), path)
|
||||
entry_issue = extract_issue_marker(branch) or path_issue
|
||||
branch_match = branch == head_branch
|
||||
issue_match = wanted_issue is not None and entry_issue == wanted_issue
|
||||
if not (branch_match or issue_match):
|
||||
continue
|
||||
safe_head = _head_is_safe_for_cleanup(
|
||||
project_root=project_root,
|
||||
candidate_head=entry.get("head_sha"),
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
if not safe_head and branch_match and not pr_head_sha and not target_ref:
|
||||
safe_head = True
|
||||
if not safe_head:
|
||||
unsafe_matches.append(path)
|
||||
continue
|
||||
candidates.append(entry)
|
||||
|
||||
state["candidate_paths"] = [entry.get("path") for entry in candidates]
|
||||
if len(candidates) == 1:
|
||||
path = candidates[0]["path"]
|
||||
alias_state = read_local_worktree_state(path)
|
||||
alias_state["worktree_path"] = path
|
||||
alias_state["expected_worktree_path"] = expected_path
|
||||
alias_state["discovered_worktree_path"] = path
|
||||
alias_state["match_type"] = (
|
||||
"branch" if candidates[0].get("branch") == head_branch else "alias"
|
||||
)
|
||||
alias_state["candidate_paths"] = [path]
|
||||
alias_state["alias_block_reasons"] = []
|
||||
alias_state["alias_verified"] = True
|
||||
return alias_state
|
||||
if len(candidates) > 1:
|
||||
state["alias_block_reasons"].append(
|
||||
"ambiguous local worktree aliases: " + ", ".join(
|
||||
sorted(str(entry.get("path")) for entry in candidates)
|
||||
)
|
||||
)
|
||||
state["match_type"] = "ambiguous_alias"
|
||||
elif unsafe_matches:
|
||||
state["alias_block_reasons"].append(
|
||||
"matching local worktree aliases did not point to the PR head or target branch: "
|
||||
+ ", ".join(sorted(unsafe_matches))
|
||||
)
|
||||
state["match_type"] = "unsafe_alias"
|
||||
return state
|
||||
|
||||
|
||||
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
|
||||
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
|
||||
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
|
||||
if not match:
|
||||
return None
|
||||
return int(match.group("pr"))
|
||||
|
||||
|
||||
def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
|
||||
"""Discover detached reviewer scratch worktrees under ``branches/`` (#534).
|
||||
|
||||
Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
|
||||
These are never treated as author worktree paths.
|
||||
"""
|
||||
root = os.path.realpath(project_root)
|
||||
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||
found: list[dict[str, Any]] = []
|
||||
for record in _git_worktree_porcelain(project_root):
|
||||
path = record.get("worktree")
|
||||
if not path:
|
||||
continue
|
||||
real = os.path.realpath(path)
|
||||
parent = os.path.dirname(real)
|
||||
if parent != branches_root:
|
||||
continue
|
||||
folder = os.path.basename(real)
|
||||
pr_number = parse_reviewer_scratch_folder(folder)
|
||||
if pr_number is None:
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": real,
|
||||
"folder_name": folder,
|
||||
"current_branch": record.get("branch"),
|
||||
"head_sha": record.get("head"),
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def assess_reviewer_scratch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
worktree_path: str,
|
||||
folder_name: str,
|
||||
pr_merged: bool,
|
||||
pr_closed: bool,
|
||||
worktree_state: dict[str, Any],
|
||||
active_reviewer_lease: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether a reviewer scratch worktree is safe to remove (#534)."""
|
||||
reasons: list[str] = []
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not (pr_merged or pr_closed):
|
||||
reasons.append("associated PR is still open")
|
||||
if not exists:
|
||||
reasons.append("reviewer scratch worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
dirty = worktree_state.get("dirty_files") or []
|
||||
reasons.append(
|
||||
"reviewer scratch worktree has tracked edits"
|
||||
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||
)
|
||||
if active_reviewer_lease:
|
||||
reasons.append("active reviewer lease still requires this worktree")
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if current_branch:
|
||||
# Scratch trees are expected detached; a named branch is ambiguous ownership.
|
||||
reasons.append(
|
||||
f"reviewer scratch worktree is on branch '{current_branch}' "
|
||||
"(expected detached HEAD for review-pr scratch trees)"
|
||||
)
|
||||
|
||||
safe = exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"worktree_path": worktree_path,
|
||||
"folder_name": folder_name,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": (
|
||||
"remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
|
||||
),
|
||||
# Never treat as author worktree cleanup.
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
|
||||
|
||||
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
|
||||
if path:
|
||||
return issue_lock_store.read_lock_file(path.strip())
|
||||
return issue_lock_store.read_session_issue_lock()
|
||||
|
||||
|
||||
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
|
||||
if lock_path:
|
||||
lock = issue_lock_store.read_lock_file(lock_path.strip())
|
||||
if not lock:
|
||||
return False
|
||||
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
|
||||
return issue_lock_store.has_active_issue_lock(branch)
|
||||
|
||||
|
||||
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
|
||||
heads: set[str] = set()
|
||||
for pr in open_prs or []:
|
||||
head = pr.get("head")
|
||||
if isinstance(head, dict):
|
||||
ref = head.get("ref")
|
||||
else:
|
||||
ref = head
|
||||
if ref:
|
||||
heads.add(str(ref))
|
||||
return heads
|
||||
|
||||
|
||||
def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
|
||||
path = (worktree_path or "").strip()
|
||||
if not path or not os.path.isdir(path):
|
||||
return {
|
||||
"exists": False,
|
||||
"clean": None,
|
||||
"current_branch": None,
|
||||
"head_sha": None,
|
||||
"dirty_files": [],
|
||||
}
|
||||
|
||||
branch_res = subprocess.run(
|
||||
["git", "-C", path, "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
current_branch = (branch_res.stdout or "").strip() or None
|
||||
|
||||
status_res = subprocess.run(
|
||||
["git", "-C", path, "status", "--porcelain"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
dirty_files = parse_dirty_tracked_files(status_res.stdout or "")
|
||||
|
||||
head_res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
|
||||
|
||||
return {
|
||||
"exists": True,
|
||||
"clean": not dirty_files,
|
||||
"current_branch": current_branch,
|
||||
"head_sha": head_sha,
|
||||
"dirty_files": dirty_files,
|
||||
}
|
||||
|
||||
|
||||
def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
|
||||
if not head_sha:
|
||||
return None
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
return True
|
||||
if res.returncode == 1:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def assess_remote_branch_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
remote_branch_exists: bool,
|
||||
open_pr_heads: set[str],
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
head_on_master: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
active_lock: bool,
|
||||
) -> dict[str, Any]:
|
||||
protected = protected_branches or PROTECTED_BRANCHES
|
||||
reasons: list[str] = []
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
if not remote_branch_exists:
|
||||
reasons.append("remote branch already absent")
|
||||
if head_branch in protected:
|
||||
reasons.append(f"branch '{head_branch}' is protected")
|
||||
if head_branch in open_pr_heads:
|
||||
reasons.append("an open PR still references this head branch")
|
||||
if active_lock:
|
||||
reasons.append("active issue lock references this branch")
|
||||
if head_on_master is False:
|
||||
reasons.append("PR head is not an ancestor of master")
|
||||
if not delete_capability_allowed:
|
||||
reasons.append("delete_branch capability is not allowed in the active profile")
|
||||
|
||||
safe = merged and remote_branch_exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"remote_branch_exists": remote_branch_exists,
|
||||
"safe_to_delete_remote": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
|
||||
}
|
||||
|
||||
|
||||
def assess_local_worktree_cleanup(
|
||||
*,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
merged: bool,
|
||||
worktree_state: dict[str, Any],
|
||||
active_lock: bool,
|
||||
) -> dict[str, Any]:
|
||||
reasons: list[str] = []
|
||||
exists = bool(worktree_state.get("exists"))
|
||||
if not merged:
|
||||
reasons.append("PR is not merged")
|
||||
reasons.extend(worktree_state.get("alias_block_reasons") or [])
|
||||
if not exists:
|
||||
reasons.append("local worktree not present")
|
||||
if exists and worktree_state.get("clean") is False:
|
||||
dirty = worktree_state.get("dirty_files") or []
|
||||
reasons.append(
|
||||
"local worktree has tracked edits"
|
||||
+ (f" ({', '.join(dirty)})" if dirty else "")
|
||||
)
|
||||
if exists:
|
||||
current_branch = worktree_state.get("current_branch")
|
||||
if (
|
||||
current_branch
|
||||
and current_branch != head_branch
|
||||
and not worktree_state.get("alias_verified")
|
||||
):
|
||||
reasons.append(
|
||||
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
|
||||
)
|
||||
if active_lock:
|
||||
reasons.append("active issue lock references this branch")
|
||||
|
||||
safe = merged and exists and not reasons
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"head_branch": head_branch,
|
||||
"worktree_path": worktree_state.get("worktree_path"),
|
||||
"expected_worktree_path": worktree_state.get("expected_worktree_path"),
|
||||
"discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
|
||||
"match_type": worktree_state.get("match_type"),
|
||||
"candidate_paths": worktree_state.get("candidate_paths") or [],
|
||||
"worktree_exists": exists,
|
||||
"worktree_clean": worktree_state.get("clean"),
|
||||
"safe_to_remove_worktree": safe,
|
||||
"block_reasons": reasons,
|
||||
"recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
|
||||
}
|
||||
|
||||
|
||||
def build_pr_cleanup_entry(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
open_pr_heads: set[str],
|
||||
remote_branch_exists: bool,
|
||||
head_on_master: bool | None,
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
pr_number = int(pr["number"])
|
||||
head_branch = pr.get("head") or ""
|
||||
if isinstance(head_branch, dict):
|
||||
head_branch = head_branch.get("ref") or ""
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
merged = bool(pr.get("merged_at"))
|
||||
issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
|
||||
head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
|
||||
pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
|
||||
worktree_state = resolve_cleanup_worktree_state(
|
||||
project_root=project_root,
|
||||
head_branch=head_branch,
|
||||
issue_number=issue_number,
|
||||
pr_head_sha=pr_head_sha,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
|
||||
|
||||
remote = assess_remote_branch_cleanup(
|
||||
pr_number=pr_number,
|
||||
head_branch=head_branch,
|
||||
merged=merged,
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
open_pr_heads=open_pr_heads,
|
||||
protected_branches=protected_branches,
|
||||
head_on_master=head_on_master,
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
local = assess_local_worktree_cleanup(
|
||||
pr_number=pr_number,
|
||||
head_branch=head_branch,
|
||||
merged=merged,
|
||||
worktree_state=worktree_state,
|
||||
active_lock=active_lock,
|
||||
)
|
||||
return {
|
||||
"pr_number": pr_number,
|
||||
"issue_number": issue_number,
|
||||
"title": title,
|
||||
"head_branch": head_branch,
|
||||
"merge_commit_sha": pr.get("merge_commit_sha"),
|
||||
"merged_at": pr.get("merged_at"),
|
||||
"merged": merged,
|
||||
"remote_branch": remote,
|
||||
"local_worktree": local,
|
||||
}
|
||||
|
||||
|
||||
def build_reconciliation_report(
|
||||
*,
|
||||
project_root: str,
|
||||
closed_prs: list[dict[str, Any]],
|
||||
open_prs: list[dict[str, Any]],
|
||||
remote_branch_exists: dict[str, bool],
|
||||
head_on_master: dict[int, bool | None],
|
||||
delete_capability_allowed: bool,
|
||||
issue_lock_path: str | None = None,
|
||||
protected_branches: frozenset[str] | None = None,
|
||||
target_ref: str | None = None,
|
||||
active_reviewer_leases: dict[int, bool] | None = None,
|
||||
pr_states: dict[int, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
open_heads = collect_open_pr_heads(open_prs)
|
||||
entries: list[dict[str, Any]] = []
|
||||
for pr in closed_prs or []:
|
||||
if not pr.get("merged_at"):
|
||||
continue
|
||||
pr_number = int(pr["number"])
|
||||
head = pr.get("head") or ""
|
||||
if isinstance(head, dict):
|
||||
head = head.get("ref") or ""
|
||||
entries.append(
|
||||
build_pr_cleanup_entry(
|
||||
pr=pr,
|
||||
project_root=project_root,
|
||||
open_pr_heads=open_heads,
|
||||
remote_branch_exists=bool(remote_branch_exists.get(head)),
|
||||
head_on_master=head_on_master.get(pr_number),
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
issue_lock_path=issue_lock_path,
|
||||
protected_branches=protected_branches,
|
||||
target_ref=target_ref,
|
||||
)
|
||||
)
|
||||
|
||||
# #534: reviewer scratch worktrees are reported separately from author cleanup.
|
||||
lease_map = active_reviewer_leases or {}
|
||||
state_map = pr_states or {}
|
||||
open_pr_numbers = {
|
||||
int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
closed_by_number = {
|
||||
int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
|
||||
}
|
||||
scratch_entries: list[dict[str, Any]] = []
|
||||
for scratch in discover_reviewer_scratch_worktrees(project_root):
|
||||
pr_number = int(scratch["pr_number"])
|
||||
state_info = state_map.get(pr_number) or {}
|
||||
closed_pr = closed_by_number.get(pr_number)
|
||||
if closed_pr is not None:
|
||||
pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
|
||||
pr_closed = True
|
||||
elif pr_number in open_pr_numbers:
|
||||
pr_merged = False
|
||||
pr_closed = False
|
||||
else:
|
||||
pr_merged = bool(state_info.get("merged"))
|
||||
pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
|
||||
worktree_path = scratch["worktree_path"]
|
||||
worktree_state = read_local_worktree_state(worktree_path)
|
||||
worktree_state["worktree_path"] = worktree_path
|
||||
# Prefer live branch from state (git) over porcelain branch field.
|
||||
assessment = assess_reviewer_scratch_cleanup(
|
||||
pr_number=pr_number,
|
||||
worktree_path=worktree_path,
|
||||
folder_name=scratch["folder_name"],
|
||||
pr_merged=pr_merged,
|
||||
pr_closed=pr_closed,
|
||||
worktree_state=worktree_state,
|
||||
active_reviewer_lease=bool(lease_map.get(pr_number)),
|
||||
)
|
||||
scratch_entries.append(assessment)
|
||||
|
||||
return {
|
||||
"project_root": os.path.realpath(project_root),
|
||||
"merged_pr_count": len(entries),
|
||||
"entries": entries,
|
||||
"reviewer_scratch_entries": scratch_entries,
|
||||
"reviewer_scratch_count": len(scratch_entries),
|
||||
"dry_run": True,
|
||||
"executed": False,
|
||||
}
|
||||
|
||||
|
||||
def remove_local_worktree(
|
||||
project_root: str,
|
||||
branch: str,
|
||||
worktree_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
|
||||
if not os.path.isdir(worktree_path):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"message": f"worktree not found: {worktree_path}",
|
||||
}
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "remove", worktree_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"message": f"removed worktree {worktree_path}",
|
||||
"worktree_path": worktree_path,
|
||||
}
|
||||
|
||||
|
||||
def remove_reviewer_scratch_worktree(
|
||||
project_root: str,
|
||||
worktree_path: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Remove a reviewer scratch worktree by absolute path (#534).
|
||||
|
||||
Fail closed unless the path is a direct ``branches/review-pr*`` child of
|
||||
*project_root*. Never routes through author branch-name derivation.
|
||||
"""
|
||||
root = os.path.realpath(project_root)
|
||||
branches_root = os.path.realpath(os.path.join(root, "branches"))
|
||||
real = os.path.realpath((worktree_path or "").strip())
|
||||
folder = os.path.basename(real)
|
||||
if os.path.dirname(real) != branches_root:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": (
|
||||
"reviewer scratch path must be a direct child of "
|
||||
f"{branches_root}; got {real}"
|
||||
),
|
||||
}
|
||||
if parse_reviewer_scratch_folder(folder) is None:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"path is not a reviewer scratch folder: {folder}",
|
||||
}
|
||||
if not os.path.isdir(real):
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"worktree not found: {real}",
|
||||
}
|
||||
res = subprocess.run(
|
||||
["git", "-C", project_root, "worktree", "remove", real],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
|
||||
"worktree_path": real,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"worktree_kind": "reviewer_scratch",
|
||||
"message": f"removed reviewer scratch worktree {real}",
|
||||
"worktree_path": real,
|
||||
"author_worktree_cleanup": False,
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Guarded merger adoption of an existing reviewer PR lease (#536).
|
||||
|
||||
Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable,
|
||||
comment-backed adoption path for merger sessions that inherit a reviewer lease
|
||||
after formal approval at the current PR head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
ADOPTION_MARKER = "<!-- mcp-review-lease-adoption:v1 -->"
|
||||
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
|
||||
|
||||
|
||||
def format_adoption_body(
|
||||
*,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
issue_number: int | None,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
worktree: str,
|
||||
candidate_head: str | None,
|
||||
target_branch: str,
|
||||
target_branch_sha: str | None,
|
||||
adopted_from_session_id: str,
|
||||
adopted_from_profile: str,
|
||||
adopted_from_reviewer_identity: str,
|
||||
adopted_from_comment_id: int | None,
|
||||
adoption_reason: str = DEFAULT_ADOPTION_REASON,
|
||||
adopted_at: datetime | None = None,
|
||||
) -> str:
|
||||
"""Render a durable merger adoption proof comment."""
|
||||
adopted_at = adopted_at or datetime.now(timezone.utc)
|
||||
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lease_body = leases.format_lease_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number,
|
||||
reviewer_identity=adopter_identity,
|
||||
profile=adopter_profile,
|
||||
session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
phase="adopted",
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
last_activity=adopted_at,
|
||||
blocker="none",
|
||||
)
|
||||
lines = [
|
||||
ADOPTION_MARKER,
|
||||
f"adopted_at: {adopted_text}",
|
||||
f"adopted_by_identity: {adopter_identity}",
|
||||
f"adopted_by_profile: {adopter_profile}",
|
||||
f"adopted_from_session_id: {adopted_from_session_id}",
|
||||
f"adopted_from_profile: {adopted_from_profile}",
|
||||
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
|
||||
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
|
||||
f"adoption_reason: {adoption_reason}",
|
||||
lease_body,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def is_adoption_comment(body: str) -> bool:
|
||||
return ADOPTION_MARKER in (body or "")
|
||||
|
||||
|
||||
def build_lease_provenance(
|
||||
*,
|
||||
source: str,
|
||||
comment_id: int | None = None,
|
||||
adopted_from_session_id: str | None = None,
|
||||
adopted_from_profile: str | None = None,
|
||||
adopted_from_reviewer_identity: str | None = None,
|
||||
adoption_reason: str | None = None,
|
||||
recorded_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
recorded_at = recorded_at or datetime.now(timezone.utc)
|
||||
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
proof = {
|
||||
"source": source,
|
||||
"recorded_at": recorded_text,
|
||||
}
|
||||
if comment_id is not None:
|
||||
proof["comment_id"] = comment_id
|
||||
if adopted_from_session_id:
|
||||
proof["adopted_from_session_id"] = adopted_from_session_id
|
||||
if adopted_from_profile:
|
||||
proof["adopted_from_profile"] = adopted_from_profile
|
||||
if adopted_from_reviewer_identity:
|
||||
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
|
||||
if adoption_reason:
|
||||
proof["adoption_reason"] = adoption_reason
|
||||
return proof
|
||||
|
||||
|
||||
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
|
||||
if not session:
|
||||
return False
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
source = (provenance.get("source") or "").strip()
|
||||
if source not in SANCTIONED_PROVENANCE_SOURCES:
|
||||
return False
|
||||
if source == SOURCE_ADOPT:
|
||||
return bool(provenance.get("comment_id")) and bool(
|
||||
provenance.get("adopted_from_session_id")
|
||||
)
|
||||
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
|
||||
return bool(session.get("comment_id") or provenance.get("comment_id"))
|
||||
return False
|
||||
|
||||
|
||||
def describe_session_lease_proof(
|
||||
session: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Canonical merge-report lease proof fields (#535).
|
||||
|
||||
Surfaces how the in-session lease was established so merge handoffs can
|
||||
distinguish sanctioned MCP acquire/adopt paths from bare manual seeding.
|
||||
"""
|
||||
if not session:
|
||||
return {
|
||||
"lease_proof_source": None,
|
||||
"lease_recovery_reason": None,
|
||||
"lease_proof_kind": "none",
|
||||
"lease_proof_sanctioned": False,
|
||||
"lease_proof_comment_id": None,
|
||||
"lease_adopted_from_session_id": None,
|
||||
}
|
||||
|
||||
provenance = session.get("lease_provenance") or {}
|
||||
if not isinstance(provenance, dict):
|
||||
provenance = {}
|
||||
source = (provenance.get("source") or "").strip() or None
|
||||
sanctioned = is_sanctioned_session_lease(session)
|
||||
reason = provenance.get("adoption_reason")
|
||||
if isinstance(reason, str):
|
||||
reason = reason.strip() or None
|
||||
else:
|
||||
reason = None
|
||||
|
||||
if source == SOURCE_ADOPT and sanctioned:
|
||||
kind = "sanctioned_adoption"
|
||||
reason = reason or DEFAULT_ADOPTION_REASON
|
||||
elif source == SOURCE_ACQUIRE and sanctioned:
|
||||
kind = "sanctioned_acquire"
|
||||
elif source == SOURCE_HEARTBEAT and sanctioned:
|
||||
kind = "sanctioned_heartbeat"
|
||||
elif source:
|
||||
kind = "unsanctioned"
|
||||
else:
|
||||
# Session present without provenance = classic manual _SESSION_LEASE seed.
|
||||
kind = "unsanctioned_manual_seed"
|
||||
|
||||
comment_id = provenance.get("comment_id")
|
||||
if comment_id is None:
|
||||
comment_id = session.get("comment_id")
|
||||
|
||||
return {
|
||||
"lease_proof_source": source,
|
||||
"lease_recovery_reason": reason,
|
||||
"lease_proof_kind": kind,
|
||||
"lease_proof_sanctioned": sanctioned,
|
||||
"lease_proof_comment_id": comment_id,
|
||||
"lease_adopted_from_session_id": provenance.get("adopted_from_session_id"),
|
||||
}
|
||||
|
||||
|
||||
# Phrases that claim non-MCP / fabricated lease proof in handoffs (#535).
|
||||
_UNSAFE_LEASE_PROOF_CLAIM_RE = re.compile(
|
||||
r"(?is)\b("
|
||||
r"manually\s+seeded\s+lease|"
|
||||
r"manual(?:ly)?\s+seed(?:ed)?\s+(?:lease|proof)|"
|
||||
r"seeded\s+lease\s+proof|"
|
||||
r"injected\s+lease|"
|
||||
r"lease\s+proof\s+injected|"
|
||||
r"manual\s+_?SESSION_LEASE|"
|
||||
r"_SESSION_LEASE\s+seed|"
|
||||
r"unsanctioned\s+lease\s+proof"
|
||||
r")\b"
|
||||
)
|
||||
|
||||
# Evidence that the report used the sanctioned MCP adoption/acquire path.
|
||||
_SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||
r"(?is)\b("
|
||||
r"gitea_adopt_merger_pr_lease|"
|
||||
r"gitea_acquire_reviewer_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||
r"adoption_comment_id\s*[:=]|"
|
||||
r"sanctioned_adoption|"
|
||||
r"mcp-review-lease-adoption"
|
||||
r")\b"
|
||||
)
|
||||
|
||||
|
||||
def assess_manual_lease_proof_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""Controller audit: block handoffs that claim unsafe lease seeding (#535).
|
||||
|
||||
Reports may discuss manual seeding as a blocked/historical anti-pattern only
|
||||
when they also cite sanctioned MCP adoption/acquire evidence. Bare claims of
|
||||
manual/seeded/injected lease proof without that evidence fail closed.
|
||||
"""
|
||||
text = report_text or ""
|
||||
if not _UNSAFE_LEASE_PROOF_CLAIM_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
if _SANCTIONED_LEASE_EVIDENCE_RE.search(text):
|
||||
return {"proven": True, "block": False, "reasons": []}
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"reasons": [
|
||||
"report claims manual/seeded/injected/unsanctioned lease proof without "
|
||||
"sanctioned MCP adoption evidence (use gitea_adopt_merger_pr_lease / "
|
||||
"gitea_acquire_reviewer_pr_lease and cite lease_proof_source; #535)"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def assess_adopt_merger_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
adopter_identity: str,
|
||||
adopter_profile: str,
|
||||
adopter_session_id: str,
|
||||
repo: str,
|
||||
issue_number: int | None,
|
||||
worktree: str,
|
||||
expected_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
approval_at_head: bool,
|
||||
pr_open: bool = True,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decide whether a merger may adopt the active reviewer lease (#536)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
pinned = leases._normalize_sha(expected_head_sha)
|
||||
live = leases._normalize_sha(live_head_sha)
|
||||
|
||||
if not pr_open:
|
||||
reasons.append(
|
||||
f"PR #{pr_number} is not open; merger lease adoption is only for open PRs"
|
||||
)
|
||||
if not approval_at_head:
|
||||
reasons.append(
|
||||
"formal APPROVED review at the current PR head is required before "
|
||||
"merger lease adoption (fail closed)"
|
||||
)
|
||||
if not pinned:
|
||||
reasons.append("expected_head_sha is required for merger lease adoption")
|
||||
if not live:
|
||||
reasons.append("live PR head SHA unavailable (fail closed)")
|
||||
elif pinned and live and pinned != live:
|
||||
reasons.append(
|
||||
"expected_head_sha does not match live PR head; refresh approval before adoption"
|
||||
)
|
||||
|
||||
active = leases.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_number, now=now
|
||||
)
|
||||
if not active:
|
||||
reasons.append(
|
||||
f"no active reviewer lease on PR #{pr_number} to adopt; reviewer "
|
||||
"must acquire first"
|
||||
)
|
||||
else:
|
||||
owner_session = (active.get("session_id") or "").strip()
|
||||
freshness = active.get("freshness") or leases.classify_lease_freshness(
|
||||
active, now=now
|
||||
)
|
||||
if freshness not in _MERGER_ADOPTABLE_FRESHNESS:
|
||||
reasons.append(
|
||||
f"active reviewer lease freshness is '{freshness}'; explicit "
|
||||
"reclaim is not implemented (fail closed)"
|
||||
)
|
||||
lease_head = active.get("candidate_head")
|
||||
if lease_head and live and lease_head != live:
|
||||
reasons.append(
|
||||
"active reviewer lease candidate_head differs from live PR head"
|
||||
)
|
||||
if owner_session and owner_session == adopter_session_id:
|
||||
# Same session already holds the thread lease — allow idempotent adopt
|
||||
# only when the newest entry is not yet an adoption by this session.
|
||||
entries = leases._lease_entries(comments, pr_number=pr_number)
|
||||
newest = entries[-1] if entries else None
|
||||
if newest and (newest.get("phase") or "") == "adopted":
|
||||
reasons.append(
|
||||
"merger session already recorded an adoption lease on this PR"
|
||||
)
|
||||
elif owner_session and owner_session != adopter_session_id:
|
||||
# Cross-session merger handoff: adopt the foreign reviewer lease.
|
||||
pass
|
||||
|
||||
if not (adopter_identity or "").strip():
|
||||
reasons.append("adopter identity required")
|
||||
if not (adopter_session_id or "").strip():
|
||||
reasons.append("adopter session_id required")
|
||||
if not (worktree or "").strip():
|
||||
reasons.append("merger worktree path required")
|
||||
if "merger" not in (adopter_profile or "").lower():
|
||||
reasons.append(
|
||||
f"profile '{adopter_profile}' is not a merger profile; adoption is "
|
||||
"merger-only (fail closed)"
|
||||
)
|
||||
|
||||
adopt_allowed = not reasons
|
||||
adoption_body = None
|
||||
if adopt_allowed and active:
|
||||
adoption_body = format_adoption_body(
|
||||
repo=repo,
|
||||
pr_number=pr_number,
|
||||
issue_number=issue_number or active.get("issue_number"),
|
||||
adopter_identity=adopter_identity,
|
||||
adopter_profile=adopter_profile,
|
||||
adopter_session_id=adopter_session_id,
|
||||
worktree=worktree,
|
||||
candidate_head=live,
|
||||
target_branch=active.get("target_branch") or "master",
|
||||
target_branch_sha=active.get("target_branch_sha"),
|
||||
adopted_from_session_id=active.get("session_id") or "",
|
||||
adopted_from_profile=active.get("profile") or "unknown",
|
||||
adopted_from_reviewer_identity=active.get("reviewer_identity") or "",
|
||||
adopted_from_comment_id=active.get("comment_id"),
|
||||
adopted_at=now,
|
||||
)
|
||||
|
||||
return {
|
||||
"adopt_allowed": adopt_allowed,
|
||||
"reasons": reasons,
|
||||
"active_lease": active,
|
||||
"adoption_body": adoption_body,
|
||||
"adopter_session_id": adopter_session_id,
|
||||
"expected_head_sha": pinned,
|
||||
"live_head_sha": live,
|
||||
}
|
||||
@@ -31,6 +31,8 @@ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
|
||||
def infer_role(name, execution_profile):
|
||||
"""Return the unambiguous role for a legacy profile name, or None."""
|
||||
haystack = f"{name} {execution_profile or ''}".lower()
|
||||
if "reconciler" in haystack:
|
||||
return "reconciler"
|
||||
has_author = "author" in haystack
|
||||
has_reviewer = "reviewer" in haystack
|
||||
if has_author == has_reviewer:
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Namespace-scoped MCP workspace binding (#510).
|
||||
|
||||
Each role namespace (author, reviewer, merger, reconciler) resolves its own
|
||||
active task workspace. Foreign role worktree environment variables must not
|
||||
poison workspace purity checks in another namespace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import author_mutation_worktree as amw
|
||||
|
||||
ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV
|
||||
AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV
|
||||
REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE"
|
||||
MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
|
||||
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
|
||||
|
||||
ROLE_WORKTREE_ENVS: dict[str, str] = {
|
||||
"author": AUTHOR_WORKTREE_ENV,
|
||||
"reviewer": REVIEWER_WORKTREE_ENV,
|
||||
"merger": MERGER_WORKTREE_ENV,
|
||||
"reconciler": RECONCILER_WORKTREE_ENV,
|
||||
}
|
||||
|
||||
NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"})
|
||||
|
||||
|
||||
def normalize_role_kind(
|
||||
role_kind: str | None,
|
||||
*,
|
||||
profile_name: str | None = None,
|
||||
) -> str:
|
||||
"""Map profile/task role to a workspace namespace key."""
|
||||
role = (role_kind or "author").strip().lower()
|
||||
profile = (profile_name or "").strip().lower()
|
||||
if role == "reviewer" and "merger" in profile:
|
||||
return "merger"
|
||||
if role in ROLE_WORKTREE_ENVS:
|
||||
return role
|
||||
return "author"
|
||||
|
||||
|
||||
def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None:
|
||||
text = (env.get(key) or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def resolve_namespace_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None = None,
|
||||
worktree: str | None = None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Return ``(resolved_path, binding_source)`` for *role_kind*."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
role_env_key = ROLE_WORKTREE_ENVS[role]
|
||||
|
||||
for candidate, source in (
|
||||
(worktree_path, "worktree_path argument"),
|
||||
(worktree, "worktree argument"),
|
||||
(_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"),
|
||||
(_env_value(env_map, role_env_key), f"{role_env_key} environment variable"),
|
||||
(session_lease_worktree if role in {"reviewer", "merger"} else None,
|
||||
"reviewer PR lease worktree"),
|
||||
):
|
||||
text = (candidate or "").strip()
|
||||
if text:
|
||||
return os.path.realpath(os.path.abspath(text)), source
|
||||
|
||||
return os.path.realpath(process_project_root), "MCP server process root (default)"
|
||||
|
||||
|
||||
def resolve_namespace_mutation_context(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": canonical_root,
|
||||
"roots_aligned": canonical_root == process_root,
|
||||
}
|
||||
|
||||
|
||||
def assess_foreign_role_worktree_pollution(
|
||||
*,
|
||||
role_kind: str,
|
||||
resolved_workspace: str,
|
||||
binding_source: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Detect when a foreign role env would have hijacked workspace binding."""
|
||||
env_map = env if env is not None else os.environ
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if role == "author":
|
||||
return {"would_pollute": False, "ignored_bindings": []}
|
||||
|
||||
ignored: list[str] = []
|
||||
author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV)
|
||||
if author_path:
|
||||
author_real = os.path.realpath(os.path.abspath(author_path))
|
||||
resolved_real = os.path.realpath(resolved_workspace)
|
||||
if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable":
|
||||
ignored.append(
|
||||
f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)"
|
||||
)
|
||||
return {
|
||||
"would_pollute": bool(ignored),
|
||||
"ignored_bindings": ignored,
|
||||
}
|
||||
|
||||
|
||||
def assess_metadata_only_worktree_binding(
|
||||
*,
|
||||
role_kind: str,
|
||||
declared_worktree_path: str | None,
|
||||
mutation_workspace: str,
|
||||
process_project_root: str,
|
||||
profile_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Fail closed when declared worktree_path would not redirect mutations."""
|
||||
declared = (declared_worktree_path or "").strip()
|
||||
process_root = os.path.realpath(process_project_root)
|
||||
mutation_root = os.path.realpath(mutation_workspace)
|
||||
role = normalize_role_kind(role_kind, profile_name=profile_name)
|
||||
if not declared:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
declared_root = os.path.realpath(os.path.abspath(declared))
|
||||
if declared_root == mutation_root:
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
if declared_root != process_root and mutation_root == process_root:
|
||||
return {
|
||||
"block": True,
|
||||
"metadata_only": True,
|
||||
"reasons": [
|
||||
f"worktree_path is metadata-only for {role} mutations: preflight "
|
||||
f"inspected '{declared_root}' but mutation tools would still "
|
||||
f"validate MCP server process root '{process_root}'"
|
||||
],
|
||||
"declared_worktree_path": declared_root,
|
||||
"mutation_workspace": mutation_root,
|
||||
"process_project_root": process_root,
|
||||
}
|
||||
|
||||
return {"block": False, "reasons": [], "metadata_only": False}
|
||||
|
||||
|
||||
def format_namespace_workspace_binding_error(
|
||||
*,
|
||||
role_kind: str,
|
||||
workspace_path: str,
|
||||
binding_source: str,
|
||||
reasons: list[str] | None = None,
|
||||
ignored_bindings: list[str] | None = None,
|
||||
dirty_files: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Canonical error when namespace workspace binding blocks mutations."""
|
||||
role = normalize_role_kind(role_kind)
|
||||
workspace = os.path.realpath(workspace_path)
|
||||
parts = [
|
||||
f"Namespace workspace binding blocked ({role} namespace, #510): "
|
||||
f"resolved workspace '{workspace}' via {binding_source}."
|
||||
]
|
||||
if ignored_bindings:
|
||||
parts.append(
|
||||
"Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "."
|
||||
)
|
||||
if dirty_files:
|
||||
parts.append(
|
||||
"Dirty tracked files in active task workspace: "
|
||||
+ ", ".join(dirty_files)
|
||||
+ "."
|
||||
)
|
||||
if reasons:
|
||||
parts.append("Details: " + "; ".join(reasons) + ".")
|
||||
parts.append(
|
||||
"Remediation: reconnect or relaunch the MCP server from a clean dedicated "
|
||||
f"branches/ {role} worktree, set "
|
||||
f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} "
|
||||
"to that path, or pass worktree_path on mutation tools. Do not clean or "
|
||||
"reset foreign role worktrees to unblock this namespace."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def assess_namespace_mutation_workspace(
|
||||
*,
|
||||
role_kind: str,
|
||||
worktree_path: str | None,
|
||||
worktree: str | None,
|
||||
process_project_root: str,
|
||||
env: dict[str, str] | os._Environ | None = None,
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
role_kind=role_kind,
|
||||
worktree_path=worktree_path,
|
||||
worktree=worktree,
|
||||
process_project_root=process_project_root,
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
role = ctx["workspace_role_kind"]
|
||||
process_root = ctx["process_project_root"]
|
||||
|
||||
metadata = assess_metadata_only_worktree_binding(
|
||||
role_kind=role,
|
||||
declared_worktree_path=worktree_path,
|
||||
mutation_workspace=mutation_workspace,
|
||||
process_project_root=process_root,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
pollution = assess_foreign_role_worktree_pollution(
|
||||
role_kind=role,
|
||||
resolved_workspace=mutation_workspace,
|
||||
binding_source=binding_source,
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
|
||||
reasons = list(metadata.get("reasons") or [])
|
||||
if role == "author":
|
||||
branches = amw.assess_author_mutation_worktree(
|
||||
workspace_path=mutation_workspace,
|
||||
project_root=ctx["canonical_repo_root"],
|
||||
current_branch=current_branch,
|
||||
)
|
||||
if branches["block"]:
|
||||
reasons.extend(branches["reasons"])
|
||||
elif (
|
||||
role == "reviewer"
|
||||
and mutation_workspace == process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace is the stable control checkout; "
|
||||
f"create or reconnect to a session-owned worktree under branches/ "
|
||||
f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}"
|
||||
)
|
||||
elif (
|
||||
role in {"reviewer", "merger"}
|
||||
and mutation_workspace != process_root
|
||||
and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"])
|
||||
):
|
||||
reasons.append(
|
||||
f"{role} mutation blocked: workspace '{mutation_workspace}' is not under "
|
||||
f"'{ctx['canonical_repo_root']}/branches/'"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"mutation_workspace": mutation_workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
"workspace_role_kind": role,
|
||||
"process_project_root": process_root,
|
||||
"canonical_repo_root": ctx["canonical_repo_root"],
|
||||
"metadata_only": metadata.get("metadata_only", False),
|
||||
"declared_worktree_path": metadata.get("declared_worktree_path"),
|
||||
"ignored_bindings": pollution.get("ignored_bindings") or [],
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Native MCP preference gate and shell health circuit breaker (#270).
|
||||
|
||||
Gitea mutations must use native MCP tools first. Shell scripts, direct API
|
||||
calls, browser helpers, and improvised encoders are blocked when MCP is
|
||||
available unless explicit recovery-mode proof is supplied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
|
||||
|
||||
# Tasks that must prefer native MCP over shell/API/helper fallbacks.
|
||||
GITEA_MUTATION_TASKS = frozenset({
|
||||
"comment_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"set_issue_labels",
|
||||
"create_issue",
|
||||
"close_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"close_pr",
|
||||
"comment_pr",
|
||||
"review_pr",
|
||||
"merge_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"delete_branch",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
ALLOWED_PATH_KINDS = frozenset({
|
||||
"mcp_native",
|
||||
"recovery_fallback",
|
||||
})
|
||||
|
||||
BLOCKED_PATH_KINDS = frozenset({
|
||||
"shell_script",
|
||||
"direct_api",
|
||||
"webfetch",
|
||||
"playwright",
|
||||
"helper_script",
|
||||
"unsafe_helper",
|
||||
"mcp_server_touch",
|
||||
})
|
||||
|
||||
SHELL_SPAWN_FAILURE_THRESHOLD = 2
|
||||
|
||||
TERMINAL_REPORT_HEADING = (
|
||||
"MCP transport unavailable or shell circuit breaker tripped. "
|
||||
"No unsafe Gitea fallback performed."
|
||||
)
|
||||
|
||||
_RECOVERY_MODE_RE = re.compile(
|
||||
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
|
||||
r"mcp tools unavailable|no mcp path)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:python3?|bash|sh)?\s*(?:"
|
||||
+ "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES)
|
||||
+ r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WEBFETCH_RE = re.compile(r"\b(?:webfetch|mcp_web_fetch|fetch\s+url)\b", re.IGNORECASE)
|
||||
_PLAYWRIGHT_RE = re.compile(r"\b(?:playwright|browser_navigate|browser_click)\b", re.IGNORECASE)
|
||||
_HELPER_SCRIPT_RE = re.compile(
|
||||
r"(?:^|[\s\"'`/])(?:_encode_|_emit_|_inline_)[\w-]+\.py",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MANUAL_BASE64_RE = re.compile(
|
||||
r"\b(?:manual\s+base64|llm-generated\s+base64|base64-encode\s+in\s+chat)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DIRECT_API_RE = re.compile(
|
||||
r"\b(?:api_request|urllib\.request|requests\.(?:post|patch|put)|curl\s+-X\s+POST)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MCP_SERVER_TOUCH_RE = re.compile(
|
||||
r"\b(?:kill|pkill|restart|reload|touch|edit|modify|write)\b.{0,40}\b(?:mcp_server|mcp-server|gitea_mcp_server)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLI_AUTH_DIVERGENCE_RE = re.compile(
|
||||
r"GITEA_MCP_PROFILE\s*=\s*['\"]?([^\s'\";]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_session_shell: dict[str, Any] = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def is_spawn_failure(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
) -> bool:
|
||||
"""True for executor spawn failures (exit_code -1, empty output)."""
|
||||
if spawn_failure is True:
|
||||
return True
|
||||
if spawn_failure is False:
|
||||
return False
|
||||
if exit_code != -1:
|
||||
return False
|
||||
return not (_clean(stdout) or _clean(stderr))
|
||||
|
||||
|
||||
def record_shell_spawn_outcome(
|
||||
*,
|
||||
exit_code: int | None = None,
|
||||
stdout: str | None = None,
|
||||
stderr: str | None = None,
|
||||
spawn_failure: bool | None = None,
|
||||
probe_attempted: bool = False,
|
||||
probe_succeeded: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Track shell spawn outcomes and trip the session circuit breaker (#270 AC4)."""
|
||||
global _session_shell
|
||||
failed = is_spawn_failure(
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
spawn_failure=spawn_failure,
|
||||
)
|
||||
|
||||
if failed:
|
||||
_session_shell["consecutive_spawn_failures"] = (
|
||||
int(_session_shell.get("consecutive_spawn_failures") or 0) + 1
|
||||
)
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
if probe_attempted and probe_succeeded is False:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
else:
|
||||
_session_shell["consecutive_spawn_failures"] = 0
|
||||
_session_shell["shell_unavailable"] = False
|
||||
_session_shell["hard_stopped"] = False
|
||||
_session_shell["last_exit_code"] = exit_code
|
||||
|
||||
failures = int(_session_shell["consecutive_spawn_failures"])
|
||||
if failures >= SHELL_SPAWN_FAILURE_THRESHOLD:
|
||||
_session_shell["shell_unavailable"] = True
|
||||
_session_shell["hard_stopped"] = True
|
||||
|
||||
return shell_health_status()
|
||||
|
||||
|
||||
def shell_health_status() -> dict[str, Any]:
|
||||
"""Return the current shell health / circuit-breaker state."""
|
||||
failures = int(_session_shell.get("consecutive_spawn_failures") or 0)
|
||||
hard_stopped = bool(_session_shell.get("hard_stopped"))
|
||||
shell_unavailable = bool(_session_shell.get("shell_unavailable"))
|
||||
return {
|
||||
"consecutive_spawn_failures": failures,
|
||||
"shell_unavailable": shell_unavailable,
|
||||
"hard_stopped": hard_stopped,
|
||||
"threshold": SHELL_SPAWN_FAILURE_THRESHOLD,
|
||||
"shell_use_allowed": not hard_stopped,
|
||||
"last_exit_code": _session_shell.get("last_exit_code"),
|
||||
"safe_next_action": (
|
||||
"emit terminal recovery report; prefer native MCP for remaining Gitea mutations"
|
||||
if hard_stopped
|
||||
else (
|
||||
"probe shell once with echo/pwd; if probe fails mark shell unavailable"
|
||||
if failures == 1
|
||||
else "shell healthy"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def clear_shell_health_for_tests() -> None:
|
||||
"""Reset session shell state (tests only)."""
|
||||
global _session_shell
|
||||
_session_shell = {
|
||||
"consecutive_spawn_failures": 0,
|
||||
"shell_unavailable": False,
|
||||
"hard_stopped": False,
|
||||
"last_exit_code": None,
|
||||
}
|
||||
|
||||
|
||||
def classify_command_path(command_or_detail: str | None) -> str:
|
||||
"""Classify a proposed command/detail into a path kind."""
|
||||
text = command_or_detail or ""
|
||||
if _MCP_SERVER_TOUCH_RE.search(text):
|
||||
return "mcp_server_touch"
|
||||
if _WEBFETCH_RE.search(text):
|
||||
return "webfetch"
|
||||
if _PLAYWRIGHT_RE.search(text):
|
||||
return "playwright"
|
||||
if _HELPER_SCRIPT_RE.search(text) or _MANUAL_BASE64_RE.search(text):
|
||||
return "unsafe_helper"
|
||||
if _LOCAL_SCRIPT_RE.search(text):
|
||||
return "shell_script"
|
||||
if _DIRECT_API_RE.search(text):
|
||||
return "direct_api"
|
||||
return "mcp_native"
|
||||
|
||||
|
||||
def detect_cli_auth_divergence(
|
||||
command_or_detail: str | None,
|
||||
*,
|
||||
active_profile: str | None,
|
||||
) -> list[str]:
|
||||
"""Flag shell commands that override GITEA_MCP_PROFILE away from the session."""
|
||||
reasons: list[str] = []
|
||||
text = command_or_detail or ""
|
||||
active = _clean(active_profile)
|
||||
match = _CLI_AUTH_DIVERGENCE_RE.search(text)
|
||||
if not match or not active:
|
||||
return reasons
|
||||
requested = _clean(match.group(1))
|
||||
if requested and requested != active:
|
||||
reasons.append(
|
||||
f"CLI auth divergence: command sets GITEA_MCP_PROFILE='{requested}' "
|
||||
f"but active session profile is '{active}' (fail closed)"
|
||||
)
|
||||
return reasons
|
||||
|
||||
|
||||
def format_mcp_unavailable_terminal_report(
|
||||
*,
|
||||
task: str | None = None,
|
||||
reasons: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Terminal report when MCP is broken and unsafe recovery is forbidden (#270 AC3)."""
|
||||
lines = [TERMINAL_REPORT_HEADING, ""]
|
||||
if task:
|
||||
lines.append(f"Blocked task: {task}")
|
||||
if reasons:
|
||||
lines.extend(["", "Reasons:"])
|
||||
lines.extend(f"- {reason}" for reason in reasons)
|
||||
lines.extend([
|
||||
"",
|
||||
"Required recovery (no improvised fallback):",
|
||||
"- Restart the MCP session",
|
||||
"- Kill hung background terminals holding the shell executor",
|
||||
"- Retry the native MCP tool once after reconnect",
|
||||
"- If MCP remains unavailable, stop and hand off to the operator",
|
||||
"- Do not run local Gitea scripts, WebFetch, Playwright, or manual base64 encoders",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_gitea_operation_path(
|
||||
*,
|
||||
task: str,
|
||||
path_kind: str | None = None,
|
||||
command_or_detail: str | None = None,
|
||||
mcp_available: bool = True,
|
||||
mcp_tool_visible: bool = True,
|
||||
recovery_mode: bool = False,
|
||||
recovery_proof_complete: bool = False,
|
||||
role: str | None = None,
|
||||
active_profile: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when a Gitea mutation would bypass native MCP (#270)."""
|
||||
task_name = _clean(task)
|
||||
resolved_kind = _clean(path_kind) or classify_command_path(command_or_detail)
|
||||
reasons: list[str] = []
|
||||
|
||||
if task_name and task_name not in GITEA_MUTATION_TASKS:
|
||||
reasons.append(
|
||||
f"unknown or non-mutation Gitea task '{task_name}'; "
|
||||
"native MCP preference gate applies only to Gitea mutations"
|
||||
)
|
||||
|
||||
shell = shell_health_status()
|
||||
if shell["hard_stopped"] and resolved_kind != "mcp_native":
|
||||
reasons.append(
|
||||
"shell circuit breaker is hard-stopped after consecutive spawn failures; "
|
||||
"use native MCP or emit terminal recovery report"
|
||||
)
|
||||
|
||||
recovery_declared = recovery_mode or bool(
|
||||
_RECOVERY_MODE_RE.search(command_or_detail or "")
|
||||
)
|
||||
recovery_allowed = (
|
||||
recovery_declared
|
||||
and recovery_proof_complete
|
||||
and not mcp_available
|
||||
and resolved_kind in BLOCKED_PATH_KINDS - {"mcp_server_touch"}
|
||||
)
|
||||
|
||||
if resolved_kind in BLOCKED_PATH_KINDS and not recovery_allowed:
|
||||
reasons.append(f"path kind '{resolved_kind}' is not an approved Gitea mutation path")
|
||||
|
||||
if resolved_kind == "mcp_server_touch":
|
||||
if _clean(role) == "reviewer" or (
|
||||
active_profile and "reviewer" in active_profile.lower()
|
||||
):
|
||||
reasons.append(
|
||||
"reviewer workflows must not touch, restart, or kill MCP server files/processes"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"MCP server files/processes must not be modified during normal Gitea workflows"
|
||||
)
|
||||
|
||||
reasons.extend(
|
||||
detect_cli_auth_divergence(command_or_detail, active_profile=active_profile)
|
||||
)
|
||||
|
||||
if (
|
||||
mcp_available
|
||||
and mcp_tool_visible
|
||||
and resolved_kind != "mcp_native"
|
||||
and not recovery_allowed
|
||||
):
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"native MCP tools are available; shell/API/helper fallback is forbidden"
|
||||
)
|
||||
elif not recovery_proof_complete:
|
||||
reasons.append(
|
||||
"recovery-mode fallback requires complete recovery proof before proceeding"
|
||||
)
|
||||
|
||||
if not mcp_available and resolved_kind != "mcp_native" and not recovery_allowed:
|
||||
if not recovery_declared:
|
||||
reasons.append(
|
||||
"MCP transport unavailable; produce terminal recovery report instead of "
|
||||
"improvising unsafe fallback"
|
||||
)
|
||||
|
||||
block = bool(reasons)
|
||||
terminal_report = None
|
||||
if block and (not mcp_available or shell["hard_stopped"]):
|
||||
terminal_report = format_mcp_unavailable_terminal_report(
|
||||
task=task_name or None,
|
||||
reasons=reasons,
|
||||
)
|
||||
|
||||
return {
|
||||
"task": task_name or None,
|
||||
"path_kind": resolved_kind,
|
||||
"mcp_available": mcp_available,
|
||||
"mcp_tool_visible": mcp_tool_visible,
|
||||
"recovery_mode": recovery_declared,
|
||||
"shell_health": shell,
|
||||
"block": block,
|
||||
"allowed": not block,
|
||||
"reasons": reasons,
|
||||
"terminal_report": terminal_report,
|
||||
"safe_next_action": (
|
||||
"use the native MCP tool for this task"
|
||||
if mcp_available and mcp_tool_visible and block
|
||||
else (
|
||||
terminal_report or "proceed with native MCP"
|
||||
if block
|
||||
else "proceed with native MCP"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def default_terminal_probe_command() -> list[str]:
|
||||
return ["git", "--version"] if shutil.which("git") else ["echo", "ok"]
|
||||
|
||||
|
||||
def _resolve_executable(cwd: str | None, executable: str | None) -> str | None:
|
||||
if not executable:
|
||||
return None
|
||||
if os.path.isabs(executable):
|
||||
if os.path.exists(executable) and os.access(executable, os.X_OK):
|
||||
return executable
|
||||
return None
|
||||
if "/" in executable or "\\" in executable:
|
||||
target_cwd = cwd or os.getcwd()
|
||||
full_path = os.path.abspath(os.path.join(target_cwd, executable))
|
||||
if os.path.exists(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
return None
|
||||
return shutil.which(executable)
|
||||
|
||||
|
||||
def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]:
|
||||
"""Inspect and categorize command spawn failures (#556)."""
|
||||
diagnostics = {
|
||||
"cwd": cwd,
|
||||
"command": command,
|
||||
"cwd_exists": True,
|
||||
"cwd_is_dir": True,
|
||||
"shell_exists": True,
|
||||
"executable_exists": True,
|
||||
"resolved_executable": None,
|
||||
"error_type": "session launcher failure",
|
||||
"error_msg": (
|
||||
"Subprocess spawn failed despite valid CWD and executable. This may be due "
|
||||
"to sandboxing, permission restrictions, or system resource limits."
|
||||
),
|
||||
}
|
||||
|
||||
if cwd:
|
||||
if not os.path.exists(cwd):
|
||||
diagnostics["cwd_exists"] = False
|
||||
diagnostics["error_type"] = "missing cwd"
|
||||
diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}"
|
||||
return diagnostics
|
||||
if not os.path.isdir(cwd):
|
||||
diagnostics["cwd_is_dir"] = False
|
||||
diagnostics["error_type"] = "cwd is not a directory"
|
||||
diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}"
|
||||
return diagnostics
|
||||
|
||||
exe = command[0] if command else None
|
||||
if exe:
|
||||
resolved_exe = _resolve_executable(cwd, exe)
|
||||
diagnostics["resolved_executable"] = resolved_exe
|
||||
if not resolved_exe:
|
||||
diagnostics["executable_exists"] = False
|
||||
if "venv" in exe or "pytest" in exe:
|
||||
diagnostics["error_type"] = "missing runtime wrapper"
|
||||
diagnostics["error_msg"] = (
|
||||
"Virtual environment runtime wrapper/executable not found or not "
|
||||
f"executable: {exe}"
|
||||
)
|
||||
else:
|
||||
diagnostics["error_type"] = "missing executable"
|
||||
diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}"
|
||||
return diagnostics
|
||||
|
||||
# Check common shell availability
|
||||
has_any_shell = False
|
||||
for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"):
|
||||
if os.path.exists(shell_path) and os.access(shell_path, os.X_OK):
|
||||
has_any_shell = True
|
||||
break
|
||||
if not has_any_shell:
|
||||
diagnostics["shell_exists"] = False
|
||||
diagnostics["error_type"] = "missing shell"
|
||||
diagnostics["error_msg"] = (
|
||||
"No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is "
|
||||
"available or executable."
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
return diagnostics
|
||||
|
||||
|
||||
def probe_terminal_spawn(
|
||||
cwd: str | None = None,
|
||||
command: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Proactively probe command spawning to detect launcher health (#556)."""
|
||||
probe_cmd = command or default_terminal_probe_command()
|
||||
diag = diagnose_terminal_failure(cwd, probe_cmd)
|
||||
if diag["error_type"] != "session launcher failure":
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": diag["error_msg"],
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
probe_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd,
|
||||
timeout=5,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return {
|
||||
"healthy": True,
|
||||
"error_type": None,
|
||||
"error_msg": "",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
# Non-zero status still proves the launcher spawned the process.
|
||||
return {
|
||||
"healthy": True,
|
||||
"error_type": None,
|
||||
"error_msg": "",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"exit_code": proc.returncode,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": diag["error_msg"],
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": "probe timeout",
|
||||
"error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"healthy": False,
|
||||
"error_type": diag["error_type"],
|
||||
"error_msg": f"Subprocess spawn failed with exception: {exc}",
|
||||
"cwd": cwd,
|
||||
"command": probe_cmd,
|
||||
"diagnostics": diag,
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Post-merge cleanup proof verifier for reviewer final reports (#402)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEANUP_SKIPPED = "CLEANUP_SKIPPED"
|
||||
CLEANUP_PERFORMED = "CLEANUP_PERFORMED"
|
||||
|
||||
_CLEANUP_SECTION_HINT = re.compile(
|
||||
r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|"
|
||||
r"gitea_delete_branch|remote branch.*deleted|worktree remove)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE)
|
||||
_CLEANUP_BLOCKER_RE = re.compile(
|
||||
r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REMOTE_DELETE_CLAIM_RE = re.compile(
|
||||
r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|"
|
||||
r"deleted remote branch|delete_branch)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_REMOVE_CLAIM_RE = re.compile(
|
||||
r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|"
|
||||
r"worktree cleanup performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_CAPABILITY_RE = re.compile(
|
||||
r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*"
|
||||
r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|"
|
||||
r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DELETE_TASK_RE = re.compile(
|
||||
r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PR_HEAD_BRANCH_RE = re.compile(
|
||||
r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCH_NOT_PROTECTED_RE = re.compile(
|
||||
r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_OPEN_PR_INVENTORY_RE = re.compile(
|
||||
r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|"
|
||||
r"open pr references).*(?:none|zero|0|clear|inventory complete)|"
|
||||
r"no other open pr references branch",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ACTIVE_CLAIM_LEASE_RE = re.compile(
|
||||
r"(?:no active (?:heartbeat|claim|lease)|"
|
||||
r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SESSION_OWNED_WORKTREE_RE = re.compile(
|
||||
r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE)
|
||||
_CLEAN_TRACKED_RE = re.compile(
|
||||
r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEAN_UNTRACKED_RE = re.compile(
|
||||
r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WORKTREE_LIST_AFTER_RE = re.compile(
|
||||
r"(?:git worktree list after|post-removal worktree list|worktree list after)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_WRONG_BRANCH_RE = re.compile(
|
||||
r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# #698: canonical reviewer lease lifecycle operations are NOT post-merge
|
||||
# cleanup. Releasing a reviewer PR lease (or posting its terminal
|
||||
# phase=released marker) happens after every review — merged or not — and
|
||||
# must never trigger the post-merge branch/worktree cleanup checklist.
|
||||
_LEASE_LIFECYCLE_RE = re.compile(
|
||||
r"(?:gitea_release_reviewer_pr_lease|gitea_abandon_workflow_lease|"
|
||||
r"release[d]? (?:the )?(?:reviewer|workflow) (?:pr )?lease|"
|
||||
r"reviewer (?:pr )?lease release[d]?|"
|
||||
r"lease (?:marker|comment).{0,40}phase\s*[:=]\s*released|"
|
||||
r"phase\s*[:=]\s*released)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CLEANUP_MUTATIONS_VALUE_RE = re.compile(
|
||||
r"cleanup mutations\s*:\s*([^\n]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _claims_remote_delete(text: str) -> bool:
|
||||
return bool(_REMOTE_DELETE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _claims_worktree_remove(text: str) -> bool:
|
||||
return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text))
|
||||
|
||||
|
||||
def _branch_safety_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not _DELETE_CAPABILITY_RE.search(text):
|
||||
missing.append("delete-branch capability resolved (gitea.branch.delete)")
|
||||
if not _DELETE_TASK_RE.search(text):
|
||||
missing.append("delete-branch task named (delete_branch or cleanup)")
|
||||
if not _MERGE_RESULT_RE.search(text):
|
||||
missing.append("merge result: merged")
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
missing.append("merge commit SHA")
|
||||
if not _PR_HEAD_BRANCH_RE.search(text):
|
||||
missing.append("merged PR head branch / deleted branch name")
|
||||
if not _BRANCH_NOT_PROTECTED_RE.search(text):
|
||||
missing.append("branch not protected proof")
|
||||
if not _OPEN_PR_INVENTORY_RE.search(text):
|
||||
missing.append("open PR inventory proof (no other PR references branch)")
|
||||
if not _ACTIVE_CLAIM_LEASE_RE.search(text):
|
||||
missing.append("no active heartbeat/claim/lease proof")
|
||||
return missing
|
||||
|
||||
|
||||
def _worktree_cleanup_fields_present(text: str) -> list[str]:
|
||||
missing: list[str] = []
|
||||
match = _SESSION_OWNED_WORKTREE_RE.search(text)
|
||||
path = match.group(1).strip() if match else ""
|
||||
if not path:
|
||||
missing.append("session-owned worktree path")
|
||||
elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")):
|
||||
missing.append("worktree path under branches/")
|
||||
if not _CLEAN_TRACKED_RE.search(text):
|
||||
missing.append("pre-removal tracked state: clean")
|
||||
if not _CLEAN_UNTRACKED_RE.search(text):
|
||||
missing.append("pre-removal untracked state: clean")
|
||||
if not _WORKTREE_LIST_AFTER_RE.search(text):
|
||||
missing.append("git worktree list after removal")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_post_merge_cleanup_proof(
|
||||
report_text: str,
|
||||
*,
|
||||
cleanup_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate post-merge cleanup claims carry safety-gate proof (#402)."""
|
||||
text = report_text or ""
|
||||
session = dict(cleanup_session or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED:
|
||||
blocker = (session.get("blocker") or "").strip()
|
||||
if not blocker:
|
||||
match = _CLEANUP_BLOCKER_RE.search(text)
|
||||
blocker = match.group(1).strip() if match else ""
|
||||
if blocker.upper() == CLEANUP_SKIPPED:
|
||||
blocker = ""
|
||||
if not blocker:
|
||||
reasons.append(
|
||||
"CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"proven": not reasons,
|
||||
"outcome": CLEANUP_SKIPPED,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
|
||||
if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"):
|
||||
return {
|
||||
"block": False,
|
||||
"proven": True,
|
||||
"outcome": None,
|
||||
"remote_delete_claimed": False,
|
||||
"worktree_remove_claimed": False,
|
||||
"reasons": [],
|
||||
"safe_next_action": "proceed",
|
||||
}
|
||||
|
||||
remote_delete = bool(
|
||||
session.get("remote_delete_claimed") or _claims_remote_delete(text)
|
||||
)
|
||||
worktree_remove = bool(
|
||||
session.get("worktree_remove_claimed") or _claims_worktree_remove(text)
|
||||
)
|
||||
|
||||
if _WRONG_BRANCH_RE.search(text):
|
||||
reasons.append(
|
||||
"cleanup report claims deleted branch that is not the merged PR head branch"
|
||||
)
|
||||
|
||||
if remote_delete:
|
||||
reasons.extend(
|
||||
f"remote branch deletion missing {field}"
|
||||
for field in _branch_safety_fields_present(text)
|
||||
)
|
||||
|
||||
if worktree_remove:
|
||||
reasons.extend(
|
||||
f"worktree removal missing {field}"
|
||||
for field in _worktree_cleanup_fields_present(text)
|
||||
)
|
||||
|
||||
if not remote_delete and not worktree_remove:
|
||||
value_match = _CLEANUP_MUTATIONS_VALUE_RE.search(text)
|
||||
value = (value_match.group(1).strip() if value_match else "")
|
||||
value_lower = value.lower()
|
||||
substantive = bool(value) and value_lower not in {
|
||||
"none", "n/a", "not applicable",
|
||||
}
|
||||
# #698: reviewer lease release / terminal lease markers are lease
|
||||
# lifecycle, not post-merge cleanup — no checklist owed.
|
||||
lease_lifecycle_only = substantive and bool(
|
||||
_LEASE_LIFECYCLE_RE.search(value)
|
||||
)
|
||||
if substantive and not lease_lifecycle_only:
|
||||
reasons.append(
|
||||
"cleanup mutations reported without post-merge cleanup proof checklist"
|
||||
)
|
||||
|
||||
outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None
|
||||
if remote_delete or worktree_remove:
|
||||
outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN"
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"block": block,
|
||||
"proven": not block,
|
||||
"outcome": outcome,
|
||||
"remote_delete_claimed": remote_delete,
|
||||
"worktree_remove_claimed": worktree_remove,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"report CLEANUP_SKIPPED with exact blocker or include the full cleanup "
|
||||
"checklist before claiming remote delete or worktree removal"
|
||||
if reasons
|
||||
else "proceed"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Canonical post-merge (moot) validation outcome and wording gate (#529).
|
||||
|
||||
When a PR is already merged or closed *before* a reviewer submits their
|
||||
verdict, an ordinary "approve" is misleading: the review never gated the
|
||||
merge, so a normal active approval overstates controller confidence. The
|
||||
sanctioned outcome for that case is a **post-merge moot validation** — the
|
||||
reviewer records what validation found, but classifies it as moot rather than
|
||||
an active approval.
|
||||
|
||||
This module defines the four canonical validation distinctions #529 requires
|
||||
and enforces the post-merge case:
|
||||
|
||||
- ``CLEAN_PASS_OUTCOME`` — clean validation pass on an open, reviewable PR.
|
||||
- ``BASELINE_ACCEPTED_OUTCOME`` — validation pass with a baseline-proven
|
||||
unrelated failure (proof handled by :mod:`premerge_baseline_proof`).
|
||||
- ``BLOCKED_OUTCOME`` — validation blocked by an unresolved failure.
|
||||
- ``POST_MERGE_MOOT_OUTCOME`` — the PR was already merged/closed before review
|
||||
submission; validation is recorded as post-merge moot, not an active
|
||||
approval.
|
||||
|
||||
The gate blocks two mistakes:
|
||||
|
||||
1. Claiming an *active approval* on a PR that was already merged/closed before
|
||||
review submission (must be recorded as post-merge moot validation instead).
|
||||
2. Claiming post-merge moot validation without proof the PR is actually
|
||||
merged/closed (a merged-state field plus a merge commit SHA).
|
||||
|
||||
Each outcome maps to a durable ``validation:*`` process-state label so PRs and
|
||||
issues carry the distinction (#529 acceptance criterion).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEAN_PASS_OUTCOME = "clean validation pass"
|
||||
BASELINE_ACCEPTED_OUTCOME = "validation pass with baseline-proven unrelated failure"
|
||||
BLOCKED_OUTCOME = "validation blocked by unresolved failure"
|
||||
POST_MERGE_MOOT_OUTCOME = "post-merge moot validation"
|
||||
|
||||
# Canonical validation status label a reviewer writes in the report body for the
|
||||
# post-merge case; kept in sync with validation_status_vocabulary.
|
||||
POST_MERGE_MOOT_STATUS = "post-merge moot validation"
|
||||
|
||||
# Durable process-state labels (registered in issue_workflow_labels).
|
||||
LABEL_CLEAN_PASS = "validation:clean-pass"
|
||||
LABEL_BASELINE_ACCEPTED = "validation:baseline-accepted"
|
||||
LABEL_BLOCKED = "validation:blocked"
|
||||
LABEL_POST_MERGE_MOOT = "validation:post-merge-moot"
|
||||
|
||||
_OUTCOME_TO_LABEL: dict[str, str] = {
|
||||
CLEAN_PASS_OUTCOME: LABEL_CLEAN_PASS,
|
||||
BASELINE_ACCEPTED_OUTCOME: LABEL_BASELINE_ACCEPTED,
|
||||
BLOCKED_OUTCOME: LABEL_BLOCKED,
|
||||
POST_MERGE_MOOT_OUTCOME: LABEL_POST_MERGE_MOOT,
|
||||
}
|
||||
|
||||
# The PR was already merged/closed before the review was submitted.
|
||||
_MERGED_BEFORE_REVIEW_RE = re.compile(
|
||||
r"(?:already[- ]merged"
|
||||
r"|merged\s+before\s+review"
|
||||
r"|closed\s+before\s+review"
|
||||
r"|pr\s+(?:is|was)\s+(?:already\s+)?(?:merged|closed)"
|
||||
r"|pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||
r"|merged\s*/\s*closed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# An active approval verdict is being claimed.
|
||||
_ACTIVE_APPROVAL_RE = re.compile(
|
||||
r"(?:review\s+decision\s*[:=]\s*approve"
|
||||
r"|submitted\s+['\"]?approve['\"]?"
|
||||
r"|active\s+approval"
|
||||
r"|approving\s+the\s+pr"
|
||||
r"|posting\s+an?\s+approval)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# The sanctioned post-merge moot validation wording.
|
||||
_POST_MERGE_MOOT_RE = re.compile(
|
||||
r"post[- ]merge\s+(?:moot\s+)?validation"
|
||||
r"|post[- ]merge\s+moot"
|
||||
r"|moot\s+validation",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Proof the PR is genuinely merged/closed.
|
||||
_MERGED_STATE_PROOF_RE = re.compile(
|
||||
r"(?:pr\s+state\s*[:=]\s*(?:merged|closed)"
|
||||
r"|merged_at\s*[:=]\s*\S"
|
||||
r"|closed_at\s*[:=]\s*\S"
|
||||
r"|state\s*[:=]\s*(?:merged|closed))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_COMMIT_SHA_RE = re.compile(
|
||||
r"merge\s+commit(?:\s+sha)?\s*[:=]\s*[0-9a-f]{7,40}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
POST_MERGE_VALIDATION_TEMPLATE = (
|
||||
"Validation status: post-merge moot validation\n"
|
||||
"PR state: merged\n"
|
||||
"Merge commit sha: <40-hex merge commit>\n"
|
||||
"Validation finding: <what the validation run showed, for the record>\n"
|
||||
"Note: PR merged/closed before review submission; recorded as post-merge "
|
||||
"moot validation, not an active approval."
|
||||
)
|
||||
|
||||
|
||||
def process_state_label(outcome: str) -> str | None:
|
||||
"""Return the durable ``validation:*`` label for a canonical outcome."""
|
||||
return _OUTCOME_TO_LABEL.get(outcome)
|
||||
|
||||
|
||||
def assess_post_merge_validation(
|
||||
report_text: str,
|
||||
*,
|
||||
pr_merged_or_closed: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess post-merge moot validation wording and proof (#529).
|
||||
|
||||
Args:
|
||||
report_text: The reviewer final report text.
|
||||
pr_merged_or_closed: Optional structured signal that the PR is already
|
||||
merged/closed. When ``True`` it forces the merged-before-review
|
||||
path even if the report text omits the phrasing; proof fields are
|
||||
still required in the report body for durability.
|
||||
|
||||
Returns a dict with ``proven``, ``block``, ``outcome``,
|
||||
``process_state_label``, ``reasons``, ``skipped`` and ``safe_next_action``.
|
||||
Only blocks when the report either claims an active approval on a
|
||||
merged/closed PR, or claims post-merge moot validation without proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
|
||||
merged_signal = bool(_MERGED_BEFORE_REVIEW_RE.search(text)) or bool(
|
||||
pr_merged_or_closed
|
||||
)
|
||||
active_approval = bool(_ACTIVE_APPROVAL_RE.search(text))
|
||||
moot_wording = bool(_POST_MERGE_MOOT_RE.search(text))
|
||||
|
||||
# Nothing about merged-state or moot validation — not applicable here.
|
||||
if not merged_signal and not moot_wording:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"outcome": None,
|
||||
"process_state_label": None,
|
||||
"reasons": [],
|
||||
"skipped": True,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
# An active approval on a PR already merged/closed before review, without
|
||||
# the sanctioned moot wording, overstates confidence.
|
||||
if merged_signal and active_approval and not moot_wording:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||
"reasons": [
|
||||
"PR was already merged/closed before review submission; an "
|
||||
"active approval overstates confidence — record 'post-merge "
|
||||
"moot validation' instead of a normal approval"
|
||||
],
|
||||
"skipped": False,
|
||||
"safe_next_action": (
|
||||
"classify the outcome as post-merge moot validation (not an "
|
||||
"active approval); cite PR state merged/closed and the merge "
|
||||
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||
),
|
||||
}
|
||||
|
||||
# Post-merge moot validation claimed — require merged-state + commit proof.
|
||||
if moot_wording:
|
||||
reasons: list[str] = []
|
||||
if not _MERGED_STATE_PROOF_RE.search(text):
|
||||
reasons.append(
|
||||
"post-merge moot validation claimed without merged/closed state "
|
||||
"proof (state: merged/closed, or merged_at/closed_at)"
|
||||
)
|
||||
if not _MERGE_COMMIT_SHA_RE.search(text):
|
||||
reasons.append(
|
||||
"post-merge moot validation claimed without a merge commit SHA"
|
||||
)
|
||||
if reasons:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||
"reasons": reasons,
|
||||
"skipped": False,
|
||||
"safe_next_action": (
|
||||
"prove the PR is merged/closed: cite PR state and the merge "
|
||||
"commit SHA. Template:\n" + POST_MERGE_VALIDATION_TEMPLATE
|
||||
),
|
||||
}
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
# Merged signal present but no approval claim and no moot wording yet —
|
||||
# guide toward the moot outcome without blocking.
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"outcome": POST_MERGE_MOOT_OUTCOME,
|
||||
"process_state_label": LABEL_POST_MERGE_MOOT,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": (
|
||||
"PR appears merged/closed; if submitting a verdict, record "
|
||||
"post-merge moot validation rather than an active approval"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"""PR-only queue cleanup mode gates and report verifier (#390).
|
||||
|
||||
Cleanup mode dispatches exactly one canonical review run per PR. It forbids
|
||||
author-side mutations (issue claiming, branch creation, implementation edits,
|
||||
issue filing) and enforces the terminal-mutation chain: stop after
|
||||
``REQUEST_CHANGES``; after ``APPROVED`` continue only to same-PR merge when
|
||||
merge is explicitly authorized for that PR and merge gates pass; stop after
|
||||
merge or a merge blocker. The next PR always requires a fresh run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
CLEANUP_WORKFLOW_PATH = "workflows/pr-queue-cleanup.md"
|
||||
|
||||
# Author-side resolver tasks that must never run inside cleanup mode.
|
||||
CLEANUP_FORBIDDEN_TASKS = frozenset({
|
||||
"claim_issue",
|
||||
"mark_issue",
|
||||
"lock_issue",
|
||||
"create_issue",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
})
|
||||
|
||||
# Run-state outcomes for one cleanup dispatch.
|
||||
STOP_AFTER_REQUEST_CHANGES = "STOP_AFTER_REQUEST_CHANGES"
|
||||
STOP_AFTER_DECISION = "STOP_AFTER_DECISION"
|
||||
CONTINUE_TO_SAME_PR_MERGE = "CONTINUE_TO_SAME_PR_MERGE"
|
||||
STOP_APPROVED_NO_MERGE_AUTH = "STOP_APPROVED_NO_MERGE_AUTH"
|
||||
STOP_APPROVED_MERGE_GATES_FAILED = "STOP_APPROVED_MERGE_GATES_FAILED"
|
||||
STOP_AFTER_MERGE = "STOP_AFTER_MERGE"
|
||||
STOP_AFTER_MERGE_BLOCKER = "STOP_AFTER_MERGE_BLOCKER"
|
||||
STOP_GATE_NOT_PROVEN = "STOP_GATE_NOT_PROVEN"
|
||||
|
||||
_TERMINAL_DECISIONS = frozenset({"approved", "request_changes", "comment", "skip"})
|
||||
|
||||
|
||||
def resolve_cleanup_run_state(
|
||||
decision: str | None,
|
||||
*,
|
||||
merge_authorized_for_pr: bool = False,
|
||||
merge_gates_passed: bool | None = None,
|
||||
merge_completed: bool = False,
|
||||
merge_blocker: bool = False,
|
||||
) -> dict:
|
||||
"""Resolve what one cleanup run may do after its terminal review decision.
|
||||
|
||||
Fail closed: unknown decisions stop the run with no further mutation.
|
||||
"""
|
||||
normalized = (decision or "").strip().lower()
|
||||
|
||||
if normalized not in _TERMINAL_DECISIONS:
|
||||
return {
|
||||
"outcome": STOP_GATE_NOT_PROVEN,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
f"unknown terminal decision {decision!r}; cleanup run stops "
|
||||
"(fail closed)"
|
||||
],
|
||||
}
|
||||
|
||||
if normalized == "request_changes":
|
||||
return {
|
||||
"outcome": STOP_AFTER_REQUEST_CHANGES,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["REQUEST_CHANGES is terminal in cleanup mode"],
|
||||
}
|
||||
|
||||
if normalized in {"comment", "skip"}:
|
||||
return {
|
||||
"outcome": STOP_AFTER_DECISION,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [f"{normalized} decision ends the cleanup run"],
|
||||
}
|
||||
|
||||
# normalized == "approved"
|
||||
if merge_completed:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge completed; cleanup run stops"],
|
||||
}
|
||||
if merge_blocker:
|
||||
return {
|
||||
"outcome": STOP_AFTER_MERGE_BLOCKER,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": ["merge blocker recorded; cleanup run stops"],
|
||||
}
|
||||
if not merge_authorized_for_pr:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_NO_MERGE_AUTH,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"APPROVED without explicit per-PR merge authorization; "
|
||||
"cleanup run stops"
|
||||
],
|
||||
}
|
||||
if merge_gates_passed is not True:
|
||||
return {
|
||||
"outcome": STOP_APPROVED_MERGE_GATES_FAILED,
|
||||
"further_mutation_allowed": False,
|
||||
"reasons": [
|
||||
"merge gates not proven passed; cleanup run stops before merge"
|
||||
],
|
||||
}
|
||||
return {
|
||||
"outcome": CONTINUE_TO_SAME_PR_MERGE,
|
||||
"further_mutation_allowed": True,
|
||||
"reasons": [],
|
||||
"allowed_mutation": "merge same PR only",
|
||||
}
|
||||
|
||||
|
||||
def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
|
||||
"""Fail closed on any author-side mutation task inside cleanup mode."""
|
||||
normalized = (task or "").strip().lower()
|
||||
if normalized in CLEANUP_FORBIDDEN_TASKS:
|
||||
return False, [
|
||||
f"task '{normalized}' is forbidden in PR-only cleanup mode: "
|
||||
"no issue claiming, branch creation, implementation edits, or "
|
||||
"issue filing"
|
||||
]
|
||||
return True, []
|
||||
|
||||
|
||||
_SELECTED_PR_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_SELECTED_PR_NONE_RE = re.compile(
|
||||
r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_EMPTY_QUEUE_RE = re.compile(
|
||||
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NEXT_SUGGESTED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
_TERMINAL_MUTATION_RE = re.compile(
|
||||
r"^\s*[-*]?\s*(?:review (?:decision|verdict)|terminal (?:review )?"
|
||||
r"(?:decision|mutation))\s*:\s*"
|
||||
r"(approved|request_changes|request changes|merged|comment)",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_PAGINATION_HINT_RE = re.compile(
|
||||
r"inventory_complete|final page|has_more\s*[=:]\s*false|total[_ ]count",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge result\s*:\s*(?!none\b|not attempted\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_MERGE_AUTHORIZED_RE = re.compile(
|
||||
r"^\s*[-*]?\s*merge authorized(?: for pr)?\s*:\s*true",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_ISSUE_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*issue mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_BRANCH_MUTATION_CLAIM_RE = re.compile(
|
||||
r"^\s*[-*]?\s*branch mutations\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def assess_pr_queue_cleanup_report(report_text: str) -> dict:
|
||||
"""Validate a PR-only cleanup run report (single dispatch, fail closed)."""
|
||||
reasons: list[str] = []
|
||||
text = report_text or ""
|
||||
|
||||
if CLEANUP_WORKFLOW_PATH not in text:
|
||||
reasons.append(
|
||||
f"report must cite the canonical cleanup workflow "
|
||||
f"({CLEANUP_WORKFLOW_PATH})"
|
||||
)
|
||||
|
||||
selected = _SELECTED_PR_RE.findall(text)
|
||||
if len(selected) == 0:
|
||||
if _SELECTED_PR_NONE_RE.search(text):
|
||||
if not _EMPTY_QUEUE_RE.search(text):
|
||||
reasons.append("Selected PR is 'none' but no valid empty-queue claim found")
|
||||
else:
|
||||
reasons.append("report must name exactly one Selected PR")
|
||||
elif len(set(selected)) > 1:
|
||||
reasons.append(
|
||||
"cleanup run selected multiple PRs "
|
||||
f"({', '.join(sorted(set(selected)))}); one canonical review per "
|
||||
"PR per run"
|
||||
)
|
||||
|
||||
if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text):
|
||||
reasons.append("report contains both a selected PR and a claim of none selected")
|
||||
|
||||
terminal = _TERMINAL_MUTATION_RE.findall(text)
|
||||
if len(terminal) > 1:
|
||||
reasons.append(
|
||||
"multiple terminal review mutations reported in one cleanup run"
|
||||
)
|
||||
|
||||
if not _PAGINATION_HINT_RE.search(text):
|
||||
reasons.append(
|
||||
"report missing PR inventory pagination proof "
|
||||
"(inventory_complete / final page / total_count)"
|
||||
)
|
||||
|
||||
if not _NEXT_SUGGESTED_RE.search(text):
|
||||
reasons.append(
|
||||
"report must include 'Next suggested PR' (without continuing to it)"
|
||||
)
|
||||
|
||||
if _MERGE_RESULT_RE.search(text) and not _MERGE_AUTHORIZED_RE.search(text):
|
||||
reasons.append(
|
||||
"merge reported without explicit per-PR merge authorization "
|
||||
"('Merge authorized: true')"
|
||||
)
|
||||
|
||||
if _ISSUE_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("issue mutations are forbidden in PR-only cleanup mode")
|
||||
if _BRANCH_MUTATION_CLAIM_RE.search(text):
|
||||
reasons.append("branch mutations are forbidden in PR-only cleanup mode")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"proceed" if proven else
|
||||
"fix the cleanup report: one PR, one terminal mutation, pagination "
|
||||
"proof, next-suggested-PR field, and no issue/branch mutations"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
|
||||
|
||||
Structured PR/issue comments prove exclusive phases so author conflict-fix
|
||||
pushes cannot race reviewer validation/approval/merge on the same head.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
|
||||
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
|
||||
|
||||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
_FIELD_RE = re.compile(
|
||||
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
|
||||
_ACTIVE_REVIEWER_PHASES = frozenset({
|
||||
"claimed",
|
||||
"validating",
|
||||
"approved",
|
||||
"request-changes",
|
||||
"merging",
|
||||
})
|
||||
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
|
||||
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
|
||||
|
||||
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
|
||||
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
|
||||
|
||||
|
||||
def _parse_timestamp(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _normalize_sha(value: str | None) -> str | None:
|
||||
text = (value or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
return text if _FULL_SHA.match(text) else None
|
||||
|
||||
|
||||
def _parse_pr_ref(value: str | None) -> int | None:
|
||||
digits = re.sub(r"[^\d]", "", value or "")
|
||||
return int(digits) if digits.isdigit() else None
|
||||
|
||||
|
||||
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
|
||||
text = body or ""
|
||||
if marker not in text:
|
||||
return None
|
||||
fields: dict[str, str] = {}
|
||||
for match in _FIELD_RE.finditer(text):
|
||||
fields[match.group(1).strip().lower()] = match.group(2).strip()
|
||||
return fields or None
|
||||
|
||||
|
||||
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
return {
|
||||
"lease_kind": "reviewer",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"issue_number": _parse_pr_ref(fields.get("issue")),
|
||||
"reviewer_identity": fields.get("reviewer_identity"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"candidate_head": _normalize_sha(fields.get("candidate_head")),
|
||||
"target_branch": fields.get("target_branch"),
|
||||
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
|
||||
"last_activity": fields.get("last_activity"),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"blocker": fields.get("blocker"),
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
|
||||
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
|
||||
if not fields:
|
||||
return None
|
||||
ff = (fields.get("fast_forward") or "").strip().lower()
|
||||
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
|
||||
return {
|
||||
"lease_kind": "conflict_fix",
|
||||
"pr_number": _parse_pr_ref(fields.get("pr")),
|
||||
"branch": fields.get("branch"),
|
||||
"worktree": fields.get("worktree"),
|
||||
"profile": fields.get("profile"),
|
||||
"session_id": fields.get("session_id"),
|
||||
"phase": (fields.get("phase") or "").strip().lower() or None,
|
||||
"head_before": _normalize_sha(fields.get("head_before")),
|
||||
"head_after": _normalize_sha(fields.get("head_after")),
|
||||
"expires_at": fields.get("expires_at"),
|
||||
"reviewer_active": reviewer_active in {"yes", "true", "1"},
|
||||
"fast_forward": ff in {"yes", "true", "1"},
|
||||
"raw_fields": fields,
|
||||
}
|
||||
|
||||
|
||||
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
|
||||
entries: list[dict] = []
|
||||
for comment in comments or []:
|
||||
body = comment.get("body") or ""
|
||||
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
|
||||
parsed = parser(body)
|
||||
if not parsed:
|
||||
continue
|
||||
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
|
||||
continue
|
||||
entries.append({
|
||||
**parsed,
|
||||
"comment_id": comment.get("id"),
|
||||
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
|
||||
"created_at": comment.get("created_at"),
|
||||
"updated_at": comment.get("updated_at"),
|
||||
})
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def _lease_expired(lease: dict, *, now: datetime) -> bool:
|
||||
expires_at = _parse_timestamp(lease.get("expires_at"))
|
||||
return bool(expires_at and expires_at <= now)
|
||||
|
||||
|
||||
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
return False
|
||||
return phase in active_phases or bool(phase and phase not in (
|
||||
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
|
||||
))
|
||||
|
||||
|
||||
def find_active_reviewer_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "reviewer"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_REVIEWER_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_REVIEWER_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def find_active_conflict_fix_lease(
|
||||
comments: list[dict],
|
||||
*,
|
||||
pr_number: int,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
candidates = [
|
||||
entry for entry in _comment_entries(comments, pr_number=pr_number)
|
||||
if entry.get("lease_kind") == "conflict_fix"
|
||||
]
|
||||
for lease in reversed(candidates):
|
||||
if _lease_expired(lease, now=now):
|
||||
continue
|
||||
phase = (lease.get("phase") or "").strip().lower()
|
||||
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
|
||||
continue
|
||||
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
|
||||
return lease
|
||||
return None
|
||||
|
||||
|
||||
def format_conflict_fix_lease_body(
|
||||
*,
|
||||
pr_number: int,
|
||||
branch: str,
|
||||
worktree: str,
|
||||
profile: str,
|
||||
head_before: str,
|
||||
phase: str = "claimed",
|
||||
session_id: str = "unknown",
|
||||
expires_at: datetime | None = None,
|
||||
reviewer_active: bool = False,
|
||||
) -> str:
|
||||
expires = expires_at or (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
|
||||
)
|
||||
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
lines = [
|
||||
CONFLICT_FIX_LEASE_MARKER,
|
||||
f"pr: #{pr_number}",
|
||||
f"branch: {branch}",
|
||||
f"worktree: {worktree}",
|
||||
f"profile: {profile}",
|
||||
f"session_id: {session_id}",
|
||||
f"phase: {phase}",
|
||||
f"head_before: {head_before}",
|
||||
f"expires_at: {expires_text}",
|
||||
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def assess_head_sha_equality(
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when reviewed and live PR heads differ."""
|
||||
reviewed = _normalize_sha(reviewed_head_sha)
|
||||
live = _normalize_sha(live_head_sha)
|
||||
reasons: list[str] = []
|
||||
if not reviewed or not live:
|
||||
reasons.append(
|
||||
"reviewed/live head SHA missing or not full 40-hex; fail closed"
|
||||
)
|
||||
elif reviewed != live:
|
||||
reasons.append(
|
||||
"PR head changed after validation; re-pin and re-validate before "
|
||||
"approval or merge"
|
||||
)
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha": live,
|
||||
"head_changed": bool(reviewed and live and reviewed != live),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_push(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
branch_head_before: str | None,
|
||||
branch_head_after: str | None,
|
||||
worktree_path: str | None,
|
||||
push_cwd: str | None,
|
||||
is_fast_forward: bool | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Author pre-push gate: block when a reviewer holds an active lease."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
|
||||
if reviewer_lease:
|
||||
reasons.append(
|
||||
f"active reviewer lease on PR #{pr_number} "
|
||||
f"(phase={reviewer_lease.get('phase')}); author push blocked"
|
||||
)
|
||||
|
||||
head_before = _normalize_sha(branch_head_before)
|
||||
head_after = _normalize_sha(branch_head_after)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push missing or invalid SHA")
|
||||
if head_after and head_before and head_before == head_after:
|
||||
reasons.append("branch head unchanged; no push to perform")
|
||||
|
||||
worktree = (worktree_path or "").strip()
|
||||
cwd = (push_cwd or "").strip()
|
||||
if not worktree:
|
||||
reasons.append("worktree path required for conflict-fix push proof")
|
||||
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
|
||||
if worktree not in cwd:
|
||||
reasons.append(
|
||||
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
|
||||
)
|
||||
|
||||
if is_fast_forward is False:
|
||||
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
|
||||
|
||||
if conflict_lease and conflict_lease.get("phase") == "pushing":
|
||||
owner = conflict_lease.get("worktree")
|
||||
if owner and worktree and owner != worktree:
|
||||
reasons.append(
|
||||
f"sibling conflict-fix lease active from worktree '{owner}'"
|
||||
)
|
||||
|
||||
push_allowed = not reasons
|
||||
return {
|
||||
"push_allowed": push_allowed,
|
||||
"block": not push_allowed,
|
||||
"reasons": reasons,
|
||||
"active_reviewer_lease": reviewer_lease,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
"reviewer_was_active": bool(reviewer_lease),
|
||||
"fast_forward": is_fast_forward,
|
||||
}
|
||||
|
||||
|
||||
def assess_reviewer_mutation_blocked(
|
||||
*,
|
||||
pr_number: int,
|
||||
comments: list[dict],
|
||||
reviewed_head_sha: str | None,
|
||||
live_head_sha: str | None,
|
||||
mutation: str,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reviewer gate: block when conflict-fix lease active or head moved."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
reasons: list[str] = []
|
||||
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
|
||||
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
|
||||
reasons.append(
|
||||
f"active conflict-fix lease on PR #{pr_number} "
|
||||
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
|
||||
)
|
||||
|
||||
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
|
||||
if head_check["block"]:
|
||||
reasons.extend(head_check["reasons"])
|
||||
|
||||
if not _normalize_sha(reviewed_head_sha):
|
||||
reasons.append(
|
||||
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
|
||||
)
|
||||
|
||||
allowed = not reasons
|
||||
return {
|
||||
"mutation_allowed": allowed,
|
||||
"block": not allowed,
|
||||
"reasons": reasons,
|
||||
"active_conflict_fix_lease": conflict_lease,
|
||||
"head_check": head_check,
|
||||
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
|
||||
"live_head_sha": head_check.get("live_head_sha"),
|
||||
"push_during_validation": bool(
|
||||
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
|
||||
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
|
||||
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PUSH_DURING_VALIDATION_RE = re.compile(
|
||||
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_BEFORE_RE = re.compile(
|
||||
r"branch head before push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONFLICT_HEAD_AFTER_RE = re.compile(
|
||||
r"branch head after push\s*:\s*([0-9a-f]{40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_LEASE_STATUS_RE = re.compile(
|
||||
r"active reviewer lease status\s*:\s*(.+)$",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_FAST_FORWARD_RE = re.compile(
|
||||
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWER_ACTIVE_RE = re.compile(
|
||||
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# #698 phase detection for phase-specific head proofs.
|
||||
_NO_REVIEWED_HEAD_RE = re.compile(
|
||||
r"(?:reviewed head sha|candidate head sha)\s*:\s*none\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VERDICT_RECORDED_RE = re.compile(
|
||||
r"review decision\s*:\s*(?:approve[d]?|request[_ ]changes)\b"
|
||||
r"|review_status\s*:\s*(?:approved|request_changes)\b"
|
||||
r"|terminal review mutation\s*:\s*(?!none\b)\S",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_ATTEMPTED_RE = re.compile(
|
||||
r"merge result\s*:\s*(?:merged|success|performed|failed|attempted)\b"
|
||||
r"|merge mutations\s*:\s*(?!none\b|not applicable\b)\S",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_VALIDATION_STARTED_RE = re.compile(
|
||||
r"validation\s*:\s*(?!none\b|not run\b|not applicable\b|not started\b)"
|
||||
r"[^\n]*(?:pass|fail|ran|executed|\d+\s+passed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6).
|
||||
|
||||
#698: head proofs are phase-specific. A legitimately blocked run that
|
||||
never began validation (no reviewed head, no formal verdict, no merge)
|
||||
owes none of them; approval-time and merge-time live-head proofs are
|
||||
owed only once the corresponding phase actually begins.
|
||||
"""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
|
||||
live_approval = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
|
||||
else None
|
||||
)
|
||||
live_merge = _normalize_sha(
|
||||
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
|
||||
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
|
||||
|
||||
# Phase detection from the report's own claims.
|
||||
no_head_stated = bool(_NO_REVIEWED_HEAD_RE.search(text))
|
||||
verdict_recorded = bool(_VERDICT_RECORDED_RE.search(text))
|
||||
merge_attempted = bool(_MERGE_ATTEMPTED_RE.search(text))
|
||||
validation_started = bool(reviewed) or bool(_VALIDATION_STARTED_RE.search(text))
|
||||
blocked_before_validation = (
|
||||
no_head_stated
|
||||
and not reviewed
|
||||
and not verdict_recorded
|
||||
and not merge_attempted
|
||||
and not validation_started
|
||||
)
|
||||
|
||||
if blocked_before_validation:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"reasons": [],
|
||||
"reviewed_head_sha": None,
|
||||
"live_head_sha_before_approval": None,
|
||||
"live_head_sha_before_merge": None,
|
||||
"push_during_validation": (
|
||||
push_during.group(1).lower() if push_during else None
|
||||
),
|
||||
"phase": "blocked_before_validation",
|
||||
}
|
||||
|
||||
if not reviewed and not no_head_stated:
|
||||
# The head must always be STATED — either a SHA or an explicit
|
||||
# 'none'. Silence is not a phase claim and fails closed.
|
||||
reasons.append(
|
||||
"reviewed head SHA not stated in final report "
|
||||
"(state the SHA or an explicit 'none')"
|
||||
)
|
||||
elif not reviewed and (validation_started or verdict_recorded or merge_attempted):
|
||||
reasons.append("reviewed head SHA not stated in final report")
|
||||
if verdict_recorded and not live_approval:
|
||||
reasons.append("final live head SHA before approval not stated")
|
||||
if merge_attempted and not live_merge:
|
||||
reasons.append("final live head SHA before merge not stated")
|
||||
if validation_started and not push_during:
|
||||
reasons.append("whether push occurred during validation not stated")
|
||||
if reviewed and live_approval and reviewed != live_approval:
|
||||
reasons.append("live head before approval differs from reviewed head SHA")
|
||||
elif reviewed and live_merge and reviewed != live_merge:
|
||||
reasons.append("live head before merge differs from reviewed head SHA")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"reviewed_head_sha": reviewed,
|
||||
"live_head_sha_before_approval": live_approval,
|
||||
"live_head_sha_before_merge": live_merge,
|
||||
"push_during_validation": (push_during.group(1).lower() if push_during else None),
|
||||
}
|
||||
|
||||
|
||||
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
|
||||
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
head_before = _normalize_sha(
|
||||
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_BEFORE_RE.search(text)
|
||||
else None
|
||||
)
|
||||
head_after = _normalize_sha(
|
||||
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
|
||||
if _CONFLICT_HEAD_AFTER_RE.search(text)
|
||||
else None
|
||||
)
|
||||
if not head_before:
|
||||
reasons.append("branch head before push not stated")
|
||||
if not head_after:
|
||||
reasons.append("branch head after push not stated")
|
||||
if not _REVIEWER_LEASE_STATUS_RE.search(text):
|
||||
reasons.append("active reviewer lease status not stated")
|
||||
if not _FAST_FORWARD_RE.search(text):
|
||||
reasons.append("whether push was fast-forward not stated")
|
||||
if not _REVIEWER_ACTIVE_RE.search(text):
|
||||
reasons.append("whether any reviewer was active not stated")
|
||||
|
||||
proven = not reasons
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"branch_head_before": head_before,
|
||||
"branch_head_after": head_after,
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Pre-merge baseline proof verifier for non-zero validation exits (#533).
|
||||
|
||||
A controller/reviewer may reproduce a failing test on *current* master and
|
||||
describe it as proof of a "known baseline failure". Reproducing a failure on
|
||||
post-merge master only proves the failure exists on current master — it does
|
||||
NOT prove the failure pre-existed the PR under review. A PR can introduce or
|
||||
preserve a regression, then once merged the failure appears on master and gets
|
||||
mislabeled "baseline", weakening validation gates.
|
||||
|
||||
This module distinguishes four canonical validation outcomes:
|
||||
|
||||
- ``CLEAN_PASS`` — the validation command exited zero.
|
||||
- ``CURRENT_MASTER_FAILURE_REPRODUCED`` — the same failure reproduces on
|
||||
current (post-merge) master. Not sufficient as baseline proof.
|
||||
- ``PREMERGE_BASELINE_PROVEN_FAILURE`` — the failure is proven on the PR's
|
||||
pre-merge base commit, or by a documented known-failure record that predates
|
||||
the PR.
|
||||
- ``UNRESOLVED_REGRESSION_RISK`` — a non-zero exit that is neither a clean pass
|
||||
nor proven pre-existing.
|
||||
|
||||
A report may only call a non-zero validation exit a "baseline"/"pre-existing"
|
||||
failure when it carries pre-merge proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
CLEAN_PASS = "clean pass"
|
||||
CURRENT_MASTER_FAILURE_REPRODUCED = "current-master failure reproduced"
|
||||
PREMERGE_BASELINE_PROVEN_FAILURE = "pre-merge baseline-proven failure"
|
||||
UNRESOLVED_REGRESSION_RISK = "unresolved regression risk"
|
||||
|
||||
# A non-zero validation exit is claimed somewhere in the report.
|
||||
_NONZERO_EXIT_RE = re.compile(
|
||||
r"(?:exit(?:\s*(?:status|code))?\s*[:=]?\s*(?:[1-9]\d*)"
|
||||
r"|\b\d+\s+failed\b"
|
||||
r"|\bnon[- ]zero\s+(?:exit|validation))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# The report claims the failure is pre-existing / a baseline failure.
|
||||
_BASELINE_CLAIM_RE = re.compile(
|
||||
r"(?:pre[- ]?existing(?:\s+(?:baseline|failure))?"
|
||||
r"|baseline(?:[- ]proven)?\s+failure"
|
||||
r"|known[- ]baseline"
|
||||
r"|failure\s+is\s+baseline"
|
||||
r"|already\s+fail(?:s|ing)\s+on\s+master)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Only-current-master reproduction language (insufficient on its own).
|
||||
_CURRENT_MASTER_RE = re.compile(
|
||||
r"(?:current[- ]master\s+(?:reproduc|failure)"
|
||||
r"|reproduc\w*\s+on\s+(?:current\s+)?master"
|
||||
r"|post[- ]merge\s+master"
|
||||
r"|fails\s+on\s+(?:current\s+)?master\s+(?:now|too|as\s+well))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Pre-merge base proof: a base commit tested before the PR landed.
|
||||
_PREMERGE_BASE_RE = re.compile(
|
||||
r"(?:pre[- ]merge\s+base(?:\s+commit)?"
|
||||
r"|pr\s+base\s+commit"
|
||||
r"|merge[- ]base(?:\s+commit)?"
|
||||
r"|base\s+commit\s+before\s+(?:the\s+)?pr)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Documented known-failure record predating the PR.
|
||||
_KNOWN_FAILURE_RECORD_RE = re.compile(
|
||||
r"known[- ]failure\s+(?:record|reference|ledger)\b.{0,80}?"
|
||||
r"(?:predat|before\s+(?:the\s+)?pr|pre[- ]existing|prior\s+to\s+(?:the\s+)?pr)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
# Required proof fields for a pre-merge baseline claim.
|
||||
_FIELD_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
"base commit": re.compile(
|
||||
r"(?:pre[- ]merge\s+)?base\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||
),
|
||||
"tested commit": re.compile(
|
||||
r"tested\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
|
||||
),
|
||||
"command": re.compile(r"command\s*[:=]\s*\S", re.IGNORECASE),
|
||||
"exit status": re.compile(r"exit\s*(?:status|code)?\s*[:=]\s*\d+", re.IGNORECASE),
|
||||
"failure signature": re.compile(
|
||||
r"failure\s+signature\s*[:=]\s*\S", re.IGNORECASE
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_premerge_baseline_proof(report_text: str) -> dict[str, Any]:
|
||||
"""Assess whether a claimed baseline failure carries pre-merge proof.
|
||||
|
||||
Returns a dict with ``proven``, ``block``, ``label``, ``reasons``,
|
||||
``skipped`` and ``safe_next_action``. Only blocks when the report claims a
|
||||
non-zero validation exit is baseline/pre-existing without valid pre-merge
|
||||
proof.
|
||||
"""
|
||||
text = report_text or ""
|
||||
|
||||
has_failure = bool(_NONZERO_EXIT_RE.search(text))
|
||||
claims_baseline = bool(_BASELINE_CLAIM_RE.search(text))
|
||||
|
||||
# No failure claimed, or no baseline assertion — nothing to enforce here.
|
||||
if not has_failure and not claims_baseline:
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": CLEAN_PASS,
|
||||
"reasons": [],
|
||||
"skipped": True,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
if not claims_baseline:
|
||||
# A non-zero exit not asserted as baseline — surface as regression risk,
|
||||
# but do not block (the report never claimed a clean/baseline pass).
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": UNRESOLVED_REGRESSION_RISK,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
|
||||
has_premerge_base = bool(_PREMERGE_BASE_RE.search(text))
|
||||
has_known_record = bool(_KNOWN_FAILURE_RECORD_RE.search(text))
|
||||
only_current_master = bool(_CURRENT_MASTER_RE.search(text))
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
if not (has_premerge_base or has_known_record):
|
||||
if only_current_master:
|
||||
reasons.append(
|
||||
"baseline failure claimed from current-master reproduction only; "
|
||||
"that proves the failure on current master, not that it pre-existed "
|
||||
"the PR — provide pre-merge base proof or a known-failure record"
|
||||
)
|
||||
else:
|
||||
reasons.append(
|
||||
"baseline/pre-existing failure claimed without pre-merge proof "
|
||||
"(no pre-merge base commit and no documented known-failure record "
|
||||
"predating the PR)"
|
||||
)
|
||||
|
||||
# Require the concrete proof fields regardless of proof source.
|
||||
missing = [name for name, pat in _FIELD_PATTERNS.items() if not pat.search(text)]
|
||||
if missing:
|
||||
reasons.append(
|
||||
"pre-merge baseline claim missing required proof field(s): "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
if reasons:
|
||||
return {
|
||||
"proven": False,
|
||||
"block": True,
|
||||
"label": UNRESOLVED_REGRESSION_RISK,
|
||||
"reasons": reasons,
|
||||
"skipped": False,
|
||||
"safe_next_action": (
|
||||
"prove the failure on the PR pre-merge base commit (or cite a "
|
||||
"known-failure record predating the PR) and state base commit, "
|
||||
"tested commit, command, exit status, and failure signature; "
|
||||
"current-master reproduction alone is not baseline proof"
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"proven": True,
|
||||
"block": False,
|
||||
"label": PREMERGE_BASELINE_PROVEN_FAILURE,
|
||||
"reasons": [],
|
||||
"skipped": False,
|
||||
"safe_next_action": "",
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Reconciler profile model for already-landed PR closure (#304).
|
||||
|
||||
Defines the narrowly scoped operation set for a dedicated reconciler profile
|
||||
such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the
|
||||
#310 stack; this module validates profile shape only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gitea_config
|
||||
|
||||
RECONCILER_REQUIRED_OPERATIONS = (
|
||||
"gitea.read",
|
||||
"gitea.pr.close",
|
||||
)
|
||||
|
||||
RECONCILER_RECOMMENDED_OPERATIONS = (
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.issue.close",
|
||||
)
|
||||
|
||||
RECONCILER_FORBIDDEN_OPERATIONS = (
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
)
|
||||
|
||||
|
||||
def _normalized_allowed(allowed: list[str]) -> set[str]:
|
||||
normalized: set[str] = set()
|
||||
for entry in allowed or []:
|
||||
try:
|
||||
normalized.add(gitea_config.normalize_operation(entry))
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return normalized
|
||||
|
||||
|
||||
def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]:
|
||||
"""Return forbidden ops that are explicitly listed in *allowed*."""
|
||||
allowed_n = _normalized_allowed(allowed)
|
||||
present: list[str] = []
|
||||
for op in ops:
|
||||
try:
|
||||
if gitea_config.normalize_operation(op) in allowed_n:
|
||||
present.append(op)
|
||||
except gitea_config.ConfigError:
|
||||
continue
|
||||
return present
|
||||
|
||||
|
||||
def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool:
|
||||
"""Return True when *allowed*/*forbidden* describe a reconciler profile."""
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
if not can("gitea.pr.close"):
|
||||
return False
|
||||
if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict:
|
||||
"""Validate reconciler profile operations (read-only, fail closed)."""
|
||||
allowed = list(allowed or [])
|
||||
forbidden = list(forbidden or [])
|
||||
reasons: list[str] = []
|
||||
|
||||
for op in RECONCILER_REQUIRED_OPERATIONS:
|
||||
ok, _ = gitea_config.check_operation(op, allowed, forbidden)
|
||||
if not ok:
|
||||
reasons.append(f"missing required operation {op}")
|
||||
|
||||
for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
|
||||
reasons.append(f"forbidden operation must not be allowed: {op}")
|
||||
|
||||
missing_recommended = [
|
||||
op for op in RECONCILER_RECOMMENDED_OPERATIONS
|
||||
if not gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
]
|
||||
|
||||
return {
|
||||
"is_reconciler_profile": is_reconciler_profile(allowed, forbidden),
|
||||
"valid": not reasons and is_reconciler_profile(allowed, forbidden),
|
||||
"reasons": reasons,
|
||||
"missing_recommended_operations": missing_recommended,
|
||||
"required_operations": list(RECONCILER_REQUIRED_OPERATIONS),
|
||||
"forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS),
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Already-landed PR reconciliation workflow helpers (#301).
|
||||
|
||||
Read-only assessment and capability planning for reconciling open PRs whose
|
||||
heads are already ancestors of the target branch. Does not invoke review or
|
||||
merge paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import gitea_config
|
||||
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
|
||||
|
||||
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
|
||||
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
|
||||
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
|
||||
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
|
||||
|
||||
OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED"
|
||||
OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP"
|
||||
OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY"
|
||||
OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION"
|
||||
OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN"
|
||||
|
||||
RECONCILE_WORKFLOW_MARKERS = (
|
||||
"workflows/reconcile-landed-pr.md",
|
||||
"reconcile-landed-pr.md",
|
||||
)
|
||||
|
||||
RECONCILE_TASK_MARKERS = (
|
||||
"reconcile-landed-pr",
|
||||
"reconcile already-landed",
|
||||
"reconcile_already_landed",
|
||||
)
|
||||
|
||||
|
||||
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
|
||||
"""Fetch *branch* from *remote* and return the resolved SHA."""
|
||||
ref = f"{remote}/{branch}"
|
||||
fetch = subprocess.run(
|
||||
["git", "-C", project_root, "fetch", remote, branch],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if fetch.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git fetch {remote} {branch} failed: "
|
||||
f"{(fetch.stderr or fetch.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
rev = subprocess.run(
|
||||
["git", "-C", project_root, "rev-parse", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if rev.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": None,
|
||||
"reasons": [
|
||||
f"git rev-parse {ref} failed: "
|
||||
f"{(rev.stderr or rev.stdout or '').strip()}"
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"target_branch": branch,
|
||||
"target_ref": ref,
|
||||
"target_branch_sha": (rev.stdout or "").strip(),
|
||||
"reasons": [],
|
||||
"git_fetch_command": f"git fetch {remote} {branch}",
|
||||
}
|
||||
|
||||
|
||||
def profile_reconciliation_capabilities(
|
||||
allowed: list[str] | None,
|
||||
forbidden: list[str] | None,
|
||||
) -> dict[str, bool]:
|
||||
"""Map active profile operations to reconciliation capabilities."""
|
||||
allowed = allowed or []
|
||||
forbidden = forbidden or []
|
||||
|
||||
def can(op: str) -> bool:
|
||||
return gitea_config.check_operation(op, allowed, forbidden)[0]
|
||||
|
||||
return {
|
||||
"read": can("gitea.read"),
|
||||
"comment_pr": can("gitea.pr.comment"),
|
||||
"comment_issue": can("gitea.issue.comment"),
|
||||
"close_pr": can("gitea.pr.close"),
|
||||
"close_issue": can("gitea.issue.close"),
|
||||
"review_pr": can("gitea.pr.review") or can("gitea.pr.approve"),
|
||||
"merge_pr": can("gitea.pr.merge"),
|
||||
}
|
||||
|
||||
|
||||
def assess_open_pr_reconciliation(
|
||||
*,
|
||||
pr: dict[str, Any],
|
||||
project_root: str,
|
||||
remote: str,
|
||||
target_branch: str,
|
||||
target_fetch: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assess whether an open PR is eligible for already-landed reconciliation."""
|
||||
pr_number = int(pr.get("number") or 0)
|
||||
pr_state = (pr.get("state") or "").strip().lower()
|
||||
head = pr.get("head") or {}
|
||||
head_sha = head.get("sha") if isinstance(head, dict) else None
|
||||
head_ref = head.get("ref") if isinstance(head, dict) else None
|
||||
base = pr.get("base") or {}
|
||||
base_ref = base.get("ref") if isinstance(base, dict) else None
|
||||
title = pr.get("title") or ""
|
||||
body = pr.get("body") or ""
|
||||
|
||||
fetch_result = target_fetch or fetch_target_branch(
|
||||
project_root, remote, target_branch
|
||||
)
|
||||
linked_issue = extract_linked_issue(title, body)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"pr_number": pr_number,
|
||||
"pr_state": pr_state,
|
||||
"candidate_head_sha": head_sha,
|
||||
"head_ref": head_ref,
|
||||
"base_ref": base_ref or target_branch,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": fetch_result.get("target_branch_sha"),
|
||||
"linked_issue": linked_issue,
|
||||
"git_ref_mutations": [],
|
||||
"reasons": [],
|
||||
"review_merge_allowed": False,
|
||||
}
|
||||
if fetch_result.get("git_fetch_command"):
|
||||
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
|
||||
|
||||
if pr_state != "open":
|
||||
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
|
||||
return result
|
||||
|
||||
if not fetch_result.get("success"):
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["ancestor_proof"] = None
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].extend(fetch_result.get("reasons") or [])
|
||||
return result
|
||||
|
||||
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
|
||||
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
|
||||
result["ancestor_proof"] = ancestor
|
||||
|
||||
if ancestor is None:
|
||||
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"ancestor check failed for head {head_sha!r} against {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
if ancestor:
|
||||
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
|
||||
result["reconciliation_allowed"] = True
|
||||
return result
|
||||
|
||||
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
|
||||
result["reconciliation_allowed"] = False
|
||||
result["reasons"].append(
|
||||
f"PR head {head_sha} is not an ancestor of {target_ref}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def resolve_reconciliation_plan(
|
||||
*,
|
||||
assessment: dict[str, Any],
|
||||
capabilities: dict[str, bool] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Map eligibility + profile capabilities to a reconciliation outcome."""
|
||||
caps = capabilities or {}
|
||||
reasons: list[str] = []
|
||||
|
||||
if not assessment.get("reconciliation_allowed"):
|
||||
outcome = OUTCOME_NOT_LANDED
|
||||
if assessment.get("eligibility_class") in {
|
||||
ELIGIBILITY_STALE_TARGET,
|
||||
ELIGIBILITY_PR_NOT_OPEN,
|
||||
}:
|
||||
outcome = OUTCOME_GATE_NOT_PROVEN
|
||||
reasons.extend(assessment.get("reasons") or [])
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": False,
|
||||
"comment_pr_allowed": False,
|
||||
"close_issue_allowed": False,
|
||||
"comment_issue_allowed": False,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": (
|
||||
"Do not reconcile via review/merge; repair missing proof first."
|
||||
),
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
close_pr = bool(caps.get("close_pr"))
|
||||
comment_pr = bool(caps.get("comment_pr"))
|
||||
close_issue = bool(caps.get("close_issue"))
|
||||
comment_issue = bool(caps.get("comment_issue"))
|
||||
|
||||
if caps.get("review_pr") or caps.get("merge_pr"):
|
||||
reasons.append(
|
||||
"reconciliation path must not use review/merge capabilities"
|
||||
)
|
||||
|
||||
if close_pr:
|
||||
outcome = OUTCOME_FULL_RECONCILE
|
||||
safe_next_action = (
|
||||
"Run reconciliation close via exact gitea.pr.close after proof; "
|
||||
"do not approve or merge."
|
||||
)
|
||||
elif comment_pr:
|
||||
outcome = OUTCOME_PARTIAL_COMMENT
|
||||
safe_next_action = (
|
||||
"Post one reconciliation comment with ancestor proof, then stop "
|
||||
"for an authorized close profile."
|
||||
)
|
||||
else:
|
||||
outcome = OUTCOME_RECOVERY_HANDOFF
|
||||
safe_next_action = (
|
||||
"Produce a recovery handoff naming missing gitea.pr.close and/or "
|
||||
"gitea.pr.comment; do not loop through review/merge."
|
||||
)
|
||||
reasons.append("gitea.pr.close is not available in the active profile")
|
||||
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"close_pr_allowed": close_pr,
|
||||
"comment_pr_allowed": comment_pr,
|
||||
"close_issue_allowed": close_issue,
|
||||
"comment_issue_allowed": comment_issue,
|
||||
"review_merge_allowed": False,
|
||||
"missing_capabilities": _missing_reconciliation_capabilities(caps),
|
||||
"safe_next_action": safe_next_action,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]:
|
||||
missing = []
|
||||
if not caps.get("read"):
|
||||
missing.append("gitea.read")
|
||||
if not caps.get("close_pr"):
|
||||
missing.append("gitea.pr.close")
|
||||
if not caps.get("comment_pr"):
|
||||
missing.append("gitea.pr.comment")
|
||||
return missing
|
||||
|
||||
|
||||
def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]:
|
||||
"""Reconciliation reports must cite the canonical workflow file."""
|
||||
lower = (report_text or "").lower()
|
||||
reasons = []
|
||||
if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing workflow source "
|
||||
"(workflows/reconcile-landed-pr.md)"
|
||||
)
|
||||
if not any(marker in lower for marker in RECONCILE_TASK_MARKERS):
|
||||
reasons.append(
|
||||
"reconciliation report missing task mode declaration "
|
||||
"(reconcile-landed-pr)"
|
||||
)
|
||||
return {
|
||||
"complete": not reasons,
|
||||
"downgraded": bool(reasons),
|
||||
"reasons": reasons,
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Remote/repo mismatch guard (#530).
|
||||
|
||||
Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES``
|
||||
table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo
|
||||
is ``Timesheet``, but the tools in this project operate on
|
||||
``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools
|
||||
worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``),
|
||||
the resolved target silently points at the wrong repository, producing false 404s
|
||||
and risking mutation of a different repo.
|
||||
|
||||
This module provides a pure assessment that compares the MCP-resolved ``org/repo``
|
||||
against the local git remote URL and fails closed on a genuine mismatch, unless the
|
||||
caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative)
|
||||
or the local remote URL is unavailable (best-effort corroboration only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
REMEDIATION = (
|
||||
"Pass explicit org= and repo= matching the local git remote on tools that "
|
||||
"accept those parameters (including mcp_get_control_plane_guide), "
|
||||
"e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools. "
|
||||
"Do not pass org/repo to tools whose schema does not list them."
|
||||
)
|
||||
|
||||
# https://host/org/repo.git or git@host:org/repo.git
|
||||
_REMOTE_URL_SLUG_RE = re.compile(
|
||||
r"(?:[:/])(?P<org>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/*$"
|
||||
)
|
||||
|
||||
|
||||
def parse_org_repo_from_remote_url(remote_url: str | None) -> tuple[str, str] | None:
|
||||
"""Best-effort parse of org/repo from a git remote URL."""
|
||||
url = (remote_url or "").strip()
|
||||
if not url:
|
||||
return None
|
||||
match = _REMOTE_URL_SLUG_RE.search(url)
|
||||
if not match:
|
||||
return None
|
||||
org = (match.group("org") or "").strip()
|
||||
repo = (match.group("repo") or "").strip()
|
||||
if not org or not repo:
|
||||
return None
|
||||
return org, repo
|
||||
|
||||
|
||||
def assess_remote_repo_match(
|
||||
*,
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
org_explicit: bool,
|
||||
repo_explicit: bool,
|
||||
) -> dict:
|
||||
"""Fail closed when the resolved org/repo disagrees with the local git remote.
|
||||
|
||||
The guard is intentionally conservative:
|
||||
|
||||
* When the caller passed both ``org`` and ``repo`` explicitly, their intent is
|
||||
authoritative and the guard never blocks.
|
||||
* When the local git remote URL is unavailable (``None``/empty), corroboration
|
||||
is impossible, so the guard does not block (best-effort only).
|
||||
* Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL
|
||||
(case-insensitive); if it does not, the guard blocks.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
|
||||
if org_explicit and repo_explicit:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
url = (local_remote_url or "").strip()
|
||||
if not url:
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
expected_slug = f"{resolved_org}/{resolved_repo}".lower()
|
||||
if expected_slug in url.lower():
|
||||
return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
reasons.append(
|
||||
f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote "
|
||||
f"'{remote}' does not match the local git remote URL '{url}'"
|
||||
)
|
||||
return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url)
|
||||
|
||||
|
||||
def format_remote_repo_guard_error(assessment: dict) -> str:
|
||||
"""Single RuntimeError message for the MCP resolver gate."""
|
||||
reasons = "; ".join(
|
||||
assessment.get("reasons") or ["remote/repo resolution mismatch"]
|
||||
)
|
||||
resolved = (
|
||||
f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}"
|
||||
)
|
||||
local = assessment.get("local_remote_url") or "(unknown)"
|
||||
return (
|
||||
f"Remote/repo guard (#530): {reasons}. "
|
||||
f"Resolved target: {resolved}; local git remote: {local}. "
|
||||
f"{REMEDIATION}"
|
||||
)
|
||||
|
||||
|
||||
def _assessment(
|
||||
proven: bool,
|
||||
reasons: list[str],
|
||||
remote: str,
|
||||
resolved_org: str,
|
||||
resolved_repo: str,
|
||||
local_remote_url: str | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"proven": proven,
|
||||
"block": not proven,
|
||||
"reasons": reasons,
|
||||
"remote": remote,
|
||||
"resolved_org": resolved_org,
|
||||
"resolved_repo": resolved_repo,
|
||||
"local_remote_url": local_remote_url,
|
||||
"remediation": REMEDIATION,
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Reviewer final-report schema verification before session output (#391).
|
||||
|
||||
Composes the composable ``assess_final_report_validator`` (#327) with
|
||||
review-specific schema gates required before a reviewer session completes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from final_report_validator import (
|
||||
assess_final_report_validator,
|
||||
validator_finding,
|
||||
_handoff_fields,
|
||||
)
|
||||
|
||||
_LEGACY_STALE_FIELDS = (
|
||||
"pinned reviewed head",
|
||||
"scratch worktree used",
|
||||
"workspace mutations",
|
||||
)
|
||||
|
||||
_REVIEWED_HEAD_RE = re.compile(
|
||||
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# #698: validation pass proof appears in several legitimate shapes —
|
||||
# "Validation: pass", "Validation: focused 50 passed; full 2665 passed",
|
||||
# or structured "validation_status: pass". Accept pass evidence anywhere in
|
||||
# the Validation field's value, not only as its first token.
|
||||
_VALIDATION_PASS_RE = re.compile(
|
||||
r"validation(?:_status)?\s*:[^\n]{0,300}?"
|
||||
r"(?:\bpass(?:ed)?\b|\bstrong\b|\bok\b|\bgreen\b|\d+\s+passed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGED_CLAIM_RE = re.compile(
|
||||
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
|
||||
_ANCESTRY_PROOF_RE = re.compile(
|
||||
r"(?:ancestor proof|target branch sha|merge commit)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ISSUE_CLOSED_RE = re.compile(
|
||||
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LIVE_ISSUE_PROOF_RE = re.compile(
|
||||
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FULL_SUITE_PASS_RE = re.compile(
|
||||
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TESTS_IGNORED_RE = re.compile(
|
||||
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_BLOCKED_HANDOFF_RE = re.compile(
|
||||
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REPLAY_COMMAND_RE = re.compile(
|
||||
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
|
||||
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
|
||||
_APPROVED_RE = re.compile(
|
||||
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
|
||||
_FINDING_READY_RE = re.compile(
|
||||
r"(?:finding ready|ready to submit|not yet submitted)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NARRATIVE_APPROVE_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
_HANDOFF_DECISION_RE = re.compile(
|
||||
r"review decision\s*:\s*(\w+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
|
||||
fields = _handoff_fields(report_text)
|
||||
findings: list[dict[str, str]] = []
|
||||
for stale in _LEGACY_STALE_FIELDS:
|
||||
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.legacy_stale_field",
|
||||
"block",
|
||||
stale.title(),
|
||||
f"legacy or stale handoff field '{stale}' must not appear in "
|
||||
"canonical reviewer final reports",
|
||||
"remove stale fields; use Candidate head SHA and precise "
|
||||
"mutation categories instead",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _REVIEWED_HEAD_RE.search(text):
|
||||
return []
|
||||
if _VALIDATION_PASS_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.reviewed_head_without_validation",
|
||||
"block",
|
||||
"Pinned reviewed head",
|
||||
"reviewed/pinned head SHA reported without validation pass proof",
|
||||
"document validation command, cwd, and pass result before pinning head SHA",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _MERGED_CLAIM_RE.search(text):
|
||||
return []
|
||||
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.merged_without_proof",
|
||||
"block",
|
||||
"Merge result",
|
||||
"merged outcome claimed without merge result and target ancestry proof",
|
||||
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _ISSUE_CLOSED_RE.search(text):
|
||||
return []
|
||||
fields = _handoff_fields(text)
|
||||
status_value = fields.get("linked issue live status", "")
|
||||
readonly_value = fields.get("read-only diagnostics", "")
|
||||
proof_blob = f"{status_value} {readonly_value}".lower()
|
||||
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.issue_closed_without_live_proof",
|
||||
"block",
|
||||
"Linked issue",
|
||||
"issue closed claimed without live linked-issue verification proof",
|
||||
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
|
||||
return []
|
||||
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.full_suite_pass_ignored_tests",
|
||||
"block",
|
||||
"Validation",
|
||||
"full suite pass claimed while tests were ignored or skipped without "
|
||||
"explicit disclosure",
|
||||
"state which tests were ignored/skipped or downgrade validation verdict",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
if not _BLOCKED_HANDOFF_RE.search(text):
|
||||
return []
|
||||
if not _REPLAY_COMMAND_RE.search(text):
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.blocked_handoff_replay",
|
||||
"block",
|
||||
"Safe next action",
|
||||
"blocked recovery handoff includes direct approve or merge replay commands",
|
||||
"restart the full workflow after the blocker clears; do not replay mutations",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
|
||||
decision_match = _HANDOFF_DECISION_RE.search(text)
|
||||
if not narrative_approve or not decision_match:
|
||||
return []
|
||||
handoff_decision = decision_match.group(1).strip().lower()
|
||||
if handoff_decision in {"approve", "approved"}:
|
||||
return []
|
||||
return [
|
||||
validator_finding(
|
||||
"reviewer.narrative_handoff_drift",
|
||||
"block",
|
||||
"Review decision",
|
||||
f"narrative approves PR but controller handoff says '{handoff_decision}'",
|
||||
"align narrative summary with controller handoff Review decision field",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
|
||||
text = report_text or ""
|
||||
findings: list[dict[str, str]] = []
|
||||
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.pending_vs_approved",
|
||||
"block",
|
||||
"Review decision",
|
||||
"report conflates PENDING review state with APPROVED outcome",
|
||||
"distinguish official submitted review from pending or ready-to-submit state",
|
||||
)
|
||||
)
|
||||
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
|
||||
if "dry-run only" not in text.lower():
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.dry_run_vs_submitted",
|
||||
"block",
|
||||
"Review mutations",
|
||||
"dry-run language mixed with official approve/submitted claims",
|
||||
"mark dry-run explicitly or document the live review mutation proof",
|
||||
)
|
||||
)
|
||||
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
|
||||
findings.append(
|
||||
validator_finding(
|
||||
"reviewer.finding_ready_vs_submitted",
|
||||
"downgrade",
|
||||
"Review decision",
|
||||
"finding-ready wording combined with submitted-approve claims",
|
||||
"state whether review was submitted live or only prepared for submission",
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
_SCHEMA_RULES = (
|
||||
_rule_legacy_stale_fields,
|
||||
_rule_reviewed_head_without_validation,
|
||||
_rule_merged_without_proof,
|
||||
_rule_issue_closed_without_live_proof,
|
||||
_rule_full_suite_pass_ignored_tests,
|
||||
_rule_blocked_handoff_replay,
|
||||
_rule_narrative_handoff_drift,
|
||||
_rule_review_state_ambiguous,
|
||||
)
|
||||
|
||||
|
||||
def _merge_validator_results(
|
||||
base: dict[str, Any],
|
||||
extra_findings: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
findings = list(base.get("findings") or []) + extra_findings
|
||||
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
|
||||
downgraded = base.get("downgraded") or any(
|
||||
f["severity"] == "downgrade" for f in extra_findings
|
||||
)
|
||||
grade = base.get("grade", "A")
|
||||
if blocked:
|
||||
grade = "blocked"
|
||||
elif downgraded and grade == "A":
|
||||
grade = "downgraded"
|
||||
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
|
||||
safe_next = base.get("safe_next_action") or "none"
|
||||
if extra_findings:
|
||||
safe_next = extra_findings[0].get("safe_next_action", safe_next)
|
||||
return {
|
||||
**base,
|
||||
"grade": grade,
|
||||
"blocked": blocked,
|
||||
"downgraded": downgraded,
|
||||
"findings": findings,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": safe_next,
|
||||
"complete": grade == "A",
|
||||
"schema_rules_applied": len(_SCHEMA_RULES),
|
||||
}
|
||||
|
||||
|
||||
def assess_review_final_report_schema(
|
||||
report_text: str,
|
||||
*,
|
||||
review_decision_lock: dict | None = None,
|
||||
linked_issue_lock: dict | None = None,
|
||||
validation_report: dict | None = None,
|
||||
action_log: list[dict] | None = None,
|
||||
mutations_observed: bool = False,
|
||||
local_edits: bool = False,
|
||||
validation_session: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate reviewer final report text before session completion (#391)."""
|
||||
base = assess_final_report_validator(
|
||||
report_text,
|
||||
"review_pr",
|
||||
review_decision_lock=review_decision_lock,
|
||||
linked_issue_lock=linked_issue_lock,
|
||||
validation_report=validation_report,
|
||||
action_log=action_log,
|
||||
mutations_observed=mutations_observed,
|
||||
local_edits=local_edits,
|
||||
validation_session=validation_session,
|
||||
)
|
||||
extra: list[dict[str, str]] = []
|
||||
for rule in _SCHEMA_RULES:
|
||||
extra.extend(rule(report_text))
|
||||
return _merge_validator_results(base, extra)
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Enforced PR review/merge workflow state machine (#290).
|
||||
|
||||
Review and merge must advance through explicit states. Any failed upstream
|
||||
gate forbids downstream approve/merge mutations until the full workflow is
|
||||
restarted after blockers clear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
REVIEW_MERGE_STATES: tuple[str, ...] = (
|
||||
"PRECHECK",
|
||||
"INVENTORY",
|
||||
"SELECT_PR",
|
||||
"PIN_HEAD_SHA",
|
||||
"CREATE_BRANCHES_WORKTREE",
|
||||
"VALIDATE",
|
||||
"REVIEW_DECISION",
|
||||
"APPROVE_OR_REQUEST_CHANGES",
|
||||
"PRE_MERGE_RECHECK",
|
||||
"MERGE",
|
||||
"POST_MERGE_REPORT",
|
||||
)
|
||||
|
||||
TERMINAL_BLOCKED_HEADING = (
|
||||
"PR review/merge workflow blocked. Restart the full workflow after blockers clear."
|
||||
)
|
||||
|
||||
_FORBIDDEN_RECOVERY_REPLAY_RE = re.compile(
|
||||
r"\b(?:approve(?:\s+pr)?\s*#?\d+|merge(?:\s+pr)?\s*#?\d+|gitea_merge_pr|"
|
||||
r"gitea_submit_pr_review|submit\s+approve|run\s+merge)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RESTART_WORKFLOW_RE = re.compile(
|
||||
r"restart(?:\s+the)?\s+full\s+workflow|rerun\s+the\s+full\s+workflow",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_READY_TO_MERGE_RE = re.compile(
|
||||
r"\bready\s+to\s+merge\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_REVIEWED_VALIDATED_RE = re.compile(
|
||||
r"\b(?:reviewed|validated|approval\s+submitted)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PRE_MERGE_REQUIRED_GATES = (
|
||||
"whoami_verified",
|
||||
"profile_runtime_verified",
|
||||
"merge_capability_verified",
|
||||
"pr_refetched",
|
||||
"reviewed_head_sha_unchanged",
|
||||
"pr_mergeable",
|
||||
"checks_passed",
|
||||
"reviewer_not_author",
|
||||
"worktree_clean",
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return (value or "").strip()
|
||||
|
||||
|
||||
def state_index(state: str) -> int:
|
||||
name = _clean(state).upper()
|
||||
try:
|
||||
return REVIEW_MERGE_STATES.index(name)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unknown review/merge state '{state}' (fail closed)") from exc
|
||||
|
||||
|
||||
def downstream_states(from_state: str) -> list[str]:
|
||||
idx = state_index(from_state)
|
||||
return list(REVIEW_MERGE_STATES[idx + 1 :])
|
||||
|
||||
|
||||
def _completed_through(state_completion: dict[str, bool], state: str) -> bool:
|
||||
return bool(state_completion.get(state))
|
||||
|
||||
|
||||
def _first_incomplete_state(state_completion: dict[str, bool]) -> str | None:
|
||||
for state in REVIEW_MERGE_STATES:
|
||||
if not _completed_through(state_completion, state):
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
def assess_workflow_blockers(
|
||||
*,
|
||||
infra_stop: bool = False,
|
||||
capability_blocked: bool = False,
|
||||
mcp_reconnect_failed: bool = False,
|
||||
stale_capability_state: bool = False,
|
||||
live_namespace_broken: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Return hard blockers that forbid all PR queue work (#290 AC3).
|
||||
|
||||
``live_namespace_broken`` fails the merge/review path closed when the live
|
||||
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
|
||||
though the tool is registered in FastMCP (#543 AC5). Feed it the
|
||||
``blocks_merge_workflow`` verdict from
|
||||
``mcp_namespace_health.classify_namespace_probe``.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
if infra_stop:
|
||||
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
|
||||
if capability_blocked:
|
||||
reasons.append("gitea_resolve_task_capability returned blocked/stop_required")
|
||||
if mcp_reconnect_failed:
|
||||
reasons.append("MCP reconnect failed; stale session state cannot be reused")
|
||||
if stale_capability_state:
|
||||
reasons.append("stale MCP capability state detected after reconnect failure")
|
||||
if live_namespace_broken:
|
||||
reasons.append(
|
||||
"live MCP namespace call path is broken (registered in FastMCP but "
|
||||
"not callable through the namespace); repair the namespace before "
|
||||
"review/merge"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"forbidden_states": list(REVIEW_MERGE_STATES) if reasons else [],
|
||||
"safe_next_action": (
|
||||
TERMINAL_BLOCKED_HEADING if reasons else "proceed with PRECHECK"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_state_advancement(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
target_state: str,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Fail closed when *target_state* is requested before upstream gates pass.
|
||||
|
||||
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
|
||||
``mcp_reconnect_failed``, ``stale_capability_state``,
|
||||
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
|
||||
``can_approve``/``can_merge`` honor the full blocker set, not just
|
||||
infra_stop/capability_blocked (#543 AC5).
|
||||
"""
|
||||
completion = dict(state_completion or {})
|
||||
target = _clean(target_state).upper()
|
||||
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||
reasons = list(blockers["reasons"])
|
||||
|
||||
try:
|
||||
target_idx = state_index(target)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": [str(exc)],
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": TERMINAL_BLOCKED_HEADING,
|
||||
}
|
||||
|
||||
if blockers["block"]:
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": None,
|
||||
"safe_next_action": blockers["safe_next_action"],
|
||||
}
|
||||
|
||||
next_allowed = _first_incomplete_state(completion)
|
||||
if next_allowed is None:
|
||||
allowed = target_idx == len(REVIEW_MERGE_STATES) - 1
|
||||
if not allowed:
|
||||
reasons.append("workflow already completed through POST_MERGE_REPORT")
|
||||
else:
|
||||
allowed = state_index(next_allowed) >= target_idx
|
||||
if not allowed:
|
||||
reasons.append(
|
||||
f"state '{target}' is forbidden until '{next_allowed}' completes"
|
||||
)
|
||||
|
||||
return {
|
||||
"target_state": target,
|
||||
"allowed": allowed and not reasons,
|
||||
"block": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"next_allowed_state": next_allowed,
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"safe_next_action": (
|
||||
f"complete state '{next_allowed}' before advancing"
|
||||
if next_allowed and not allowed
|
||||
else (
|
||||
TERMINAL_BLOCKED_HEADING
|
||||
if reasons
|
||||
else f"advance to {target}"
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def can_approve(state_completion: dict[str, bool] | None, **blocker_kwargs) -> dict[str, Any]:
|
||||
"""Approval requires all states through REVIEW_DECISION (#290 AC)."""
|
||||
required = REVIEW_MERGE_STATES[: REVIEW_MERGE_STATES.index("REVIEW_DECISION") + 1]
|
||||
completion = dict(state_completion or {})
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="APPROVE_OR_REQUEST_CHANGES",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
missing = [s for s in required if not completion.get(s)]
|
||||
reasons = list(advance["reasons"])
|
||||
if missing:
|
||||
reasons.append(
|
||||
"approval blocked: incomplete states: " + ", ".join(missing)
|
||||
)
|
||||
block = bool(reasons) or advance["block"]
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_states": missing,
|
||||
"safe_next_action": advance["safe_next_action"],
|
||||
}
|
||||
|
||||
|
||||
def can_merge(
|
||||
state_completion: dict[str, bool] | None,
|
||||
*,
|
||||
pre_merge_gates: dict[str, bool] | None = None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge requires approve path plus fresh PRE_MERGE_RECHECK gates (#290 AC6)."""
|
||||
completion = dict(state_completion or {})
|
||||
approve = can_approve(completion, **blocker_kwargs)
|
||||
reasons = list(approve["reasons"])
|
||||
|
||||
if not completion.get("APPROVE_OR_REQUEST_CHANGES"):
|
||||
reasons.append("merge blocked: APPROVE_OR_REQUEST_CHANGES not completed")
|
||||
|
||||
gate_map = dict(pre_merge_gates or {})
|
||||
missing_gates = [g for g in _PRE_MERGE_REQUIRED_GATES if not gate_map.get(g)]
|
||||
if missing_gates:
|
||||
reasons.append(
|
||||
"merge blocked: pre-merge gates incomplete: " + ", ".join(missing_gates)
|
||||
)
|
||||
|
||||
advance = assess_state_advancement(
|
||||
completion,
|
||||
target_state="MERGE",
|
||||
**blocker_kwargs,
|
||||
)
|
||||
reasons.extend(advance["reasons"])
|
||||
|
||||
block = bool(reasons)
|
||||
return {
|
||||
"allowed": not block,
|
||||
"block": block,
|
||||
"reasons": reasons,
|
||||
"missing_pre_merge_gates": missing_gates,
|
||||
"safe_next_action": (
|
||||
"complete PRE_MERGE_RECHECK with fresh whoami/capability/PR re-fetch "
|
||||
"before merge"
|
||||
if block
|
||||
else "merge allowed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_blocked_recovery_handoff(report_text: str) -> dict[str, Any]:
|
||||
"""Blocked handoffs must not replay approve/merge commands (#290 AC4)."""
|
||||
text = report_text or ""
|
||||
reasons: list[str] = []
|
||||
if _FORBIDDEN_RECOVERY_REPLAY_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff contains direct approve/merge replay command"
|
||||
)
|
||||
if reasons and not _RESTART_WORKFLOW_RE.search(text):
|
||||
reasons.append(
|
||||
"blocked recovery handoff must direct operator to restart full workflow"
|
||||
)
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove approve/merge replay commands and say to restart full workflow "
|
||||
"after blockers clear"
|
||||
if reasons
|
||||
else "recovery handoff wording acceptable"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def assess_final_report_state_claims(
|
||||
report_text: str,
|
||||
*,
|
||||
state_completion: dict[str, bool] | None = None,
|
||||
approve_completed: bool = False,
|
||||
merge_completed: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Reports must not claim reviewed/ready-to-merge without gate proof (#290 AC10)."""
|
||||
text = report_text or ""
|
||||
completion = dict(state_completion or {})
|
||||
reasons: list[str] = []
|
||||
|
||||
if _READY_TO_MERGE_RE.search(text) and not (
|
||||
merge_completed or completion.get("PRE_MERGE_RECHECK")
|
||||
):
|
||||
reasons.append(
|
||||
"report claims ready-to-merge without PRE_MERGE_RECHECK completion"
|
||||
)
|
||||
|
||||
if _REVIEWED_VALIDATED_RE.search(text):
|
||||
if not (approve_completed or completion.get("VALIDATE")):
|
||||
reasons.append(
|
||||
"report claims reviewed/validated without VALIDATE/APPROVE proof"
|
||||
)
|
||||
|
||||
return {
|
||||
"block": bool(reasons),
|
||||
"allowed": not reasons,
|
||||
"reasons": reasons,
|
||||
"safe_next_action": (
|
||||
"remove stale reviewed/ready-to-merge claims unless gates passed"
|
||||
if reasons
|
||||
else "final report state claims consistent with workflow"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def workflow_status(
|
||||
state_completion: dict[str, bool] | None,
|
||||
**blocker_kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize current state-machine position for MCP/runtime reporting."""
|
||||
completion = dict(state_completion or {})
|
||||
blockers = assess_workflow_blockers(**blocker_kwargs)
|
||||
next_state = _first_incomplete_state(completion)
|
||||
return {
|
||||
"states": list(REVIEW_MERGE_STATES),
|
||||
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
|
||||
"next_required_state": next_state,
|
||||
"workflow_complete": next_state is None,
|
||||
"approve_allowed": can_approve(completion, **blocker_kwargs)["allowed"],
|
||||
"merge_allowed": can_merge(completion, **blocker_kwargs)["allowed"],
|
||||
"blockers": blockers,
|
||||
}
|
||||
+16
-85
@@ -7,16 +7,16 @@ making any API call. Merge is handled solely by the gated `gitea_merge_pr` MCP
|
||||
workflow (#16), which enforces identity/profile/eligibility, explicit
|
||||
confirmation, expected head SHA checking, and self-merge protection.
|
||||
|
||||
Usage (review only):
|
||||
Live review submission is also disabled (#211): use the gated
|
||||
``gitea_submit_pr_review`` MCP workflow, which enforces validation-phase
|
||||
dry-run, final decision marking, and single-terminal review mutation rules.
|
||||
|
||||
Usage (review only — disabled):
|
||||
review_pr.py --pr-number 12 --event APPROVE --body "Approved and signed off"
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import argparse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Auto-execute using the project's local virtual environment Python
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -24,18 +24,10 @@ venv_python = os.path.join(PROJECT_ROOT, "venv", "bin", "python3")
|
||||
if os.path.exists(venv_python) and sys.executable != venv_python:
|
||||
os.execv(venv_python, [venv_python] + sys.argv)
|
||||
|
||||
from gitea_auth import get_auth_header, resolve_remote, add_remote_args, api_request, repo_api_url, get_profile
|
||||
from gitea_auth import add_remote_args
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
if os.environ.get("GITEA_SESSION_PROFILE_LOCK"):
|
||||
print(
|
||||
"Direct CLI review submission is disabled within MCP sessions. "
|
||||
"Use MCP tools instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
parser = argparse.ArgumentParser(description="Review and sign-off on a Gitea pull request.")
|
||||
add_remote_args(parser)
|
||||
parser.add_argument("--pr-number", type=int, required=True, help="PR number/index to review.")
|
||||
@@ -50,87 +42,26 @@ def main(argv=None):
|
||||
help="Ignored — CLI merge is disabled (see --merge).")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Fail closed: direct CLI merge is disabled (#16). LLM automations were
|
||||
# using this flag as an ungated merge bypass. Merge is only available via
|
||||
# the gated `gitea_merge_pr` MCP workflow, which enforces
|
||||
# identity/profile/eligibility, explicit confirmation, expected head SHA,
|
||||
# and self-merge protection. No API call is made here.
|
||||
if args.merge:
|
||||
print(
|
||||
"Direct CLI merge is disabled. Merge is only available through the "
|
||||
"gated #16 workflow (MCP tool 'gitea_merge_pr'), which enforces "
|
||||
"identity/profile/eligibility, explicit confirmation, expected head "
|
||||
"SHA checking, and self-merge protection. Re-run without --merge to "
|
||||
"submit a review only.",
|
||||
"see the review-submission guard message.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
host, org, repo = resolve_remote(args)
|
||||
|
||||
# ── Reviewer mutation side-channel wall (#199, refs #194) ──
|
||||
# The launching MCP session exports GITEA_SESSION_PROFILE_LOCK with the
|
||||
# profile it was started with; child processes inherit it. If this CLI
|
||||
# resolves a different profile — e.g. an ad-hoc GITEA_MCP_PROFILE
|
||||
# override escalating an author-bound session to reviewer — refuse
|
||||
# before any API call. No lock in the environment means no session
|
||||
# context (direct operator CLI use), which stays allowed. Unlike a /tmp
|
||||
# lock file, the environment is per-process-tree: other sessions cannot
|
||||
# spoof it and it cannot go stale across sessions.
|
||||
session_lock = (os.environ.get("GITEA_SESSION_PROFILE_LOCK") or "").strip()
|
||||
if session_lock:
|
||||
print(
|
||||
"Direct CLI review submission is disabled within MCP sessions. "
|
||||
"Use the gated 'gitea_submit_pr_review' MCP tool instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
body = args.body
|
||||
if args.body_file:
|
||||
if args.body_file == "-":
|
||||
body = sys.stdin.read()
|
||||
else:
|
||||
with open(args.body_file, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
|
||||
auth = get_auth_header(host)
|
||||
if not auth:
|
||||
print(f"Could not get credentials or token for {host}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 1. Fetch PR to get the latest head commit SHA (required for review validation)
|
||||
pr_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}"
|
||||
try:
|
||||
pr_data = api_request("GET", pr_url, auth)
|
||||
except Exception as e:
|
||||
print(f"Error fetching PR #{args.pr_number}: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
commit_sha = pr_data.get("head", {}).get("sha")
|
||||
if not commit_sha:
|
||||
print(f"Could not find head commit SHA for PR #{args.pr_number}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 2. Submit the PR review
|
||||
review_url = f"{repo_api_url(host, org, repo)}/pulls/{args.pr_number}/reviews"
|
||||
payload = {
|
||||
"body": body,
|
||||
"event": args.event,
|
||||
"commit_id": commit_sha
|
||||
}
|
||||
|
||||
try:
|
||||
api_request("POST", review_url, auth, payload)
|
||||
print(f"Successfully submitted review for PR #{args.pr_number}: event={args.event}")
|
||||
except Exception as e:
|
||||
print(f"Error submitting review: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Merge is intentionally not performed here — see the fail-closed guard
|
||||
# above. Use the gated `gitea_merge_pr` MCP workflow (#16) to merge.
|
||||
return 0
|
||||
print(
|
||||
"Direct CLI review submission is disabled (#211). Use the gated "
|
||||
"'gitea_submit_pr_review' MCP workflow, which enforces validation-phase "
|
||||
"dry-run, gitea_mark_final_review_decision, and single-terminal review "
|
||||
"mutation rules.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(main())
|
||||
+4774
-8
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user