Skip to content

Commit 4bd04b2

Browse files
committed
fix(ci): retire superseded Ladon change requests
1 parent 64725d7 commit 4bd04b2

4 files changed

Lines changed: 195 additions & 1 deletion

File tree

.github/workflows/ai-review.yml

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ on:
1616
paths-ignore:
1717
- ".github/workflows/ai-review.yml"
1818
- "LADON.md"
19+
- "scripts/retire-superseded-ladon-reviews.cjs"
1920

2021
jobs:
2122
code_review:
@@ -60,7 +61,7 @@ jobs:
6061
MODIFIED=""
6162
while IFS= read -r f; do
6263
case "$f" in
63-
.github/workflows/ai-review.yml|LADON.md)
64+
.github/workflows/ai-review.yml|LADON.md|scripts/retire-superseded-ladon-reviews.cjs)
6465
MODIFIED="${MODIFIED}${f}"$'\n' ;;
6566
esac
6667
done <<< "$CHANGED"
@@ -104,3 +105,28 @@ jobs:
104105
skip-bot-authors: dependabot[bot],renovate[bot],github-actions[bot],aao-ipr-bot[bot]
105106
# Optional; defaults to the review action's pinned model.
106107
model: claude-opus-4-8
108+
109+
# GitHub keeps an earlier CHANGES_REQUESTED review blocking even after
110+
# the same App approves a corrected head. Retire only Ladon's own older
111+
# change requests, and only when its latest review approves this head.
112+
- name: Retire superseded Ladon change requests
113+
if: steps.workflow-mod.outputs.modified != 'true'
114+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
115+
env:
116+
LADON_BOT_LOGIN: ${{ steps.app-token.outputs.app-slug }}[bot]
117+
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
118+
with:
119+
github-token: ${{ steps.app-token.outputs.token }}
120+
script: |
121+
const { retireSupersededLadonReviews } = require(
122+
`${process.env.GITHUB_WORKSPACE}/scripts/retire-superseded-ladon-reviews.cjs`
123+
);
124+
const dismissed = await retireSupersededLadonReviews({
125+
github,
126+
owner: context.repo.owner,
127+
repo: context.repo.repo,
128+
pullNumber: context.issue.number,
129+
botLogin: process.env.LADON_BOT_LOGIN,
130+
headSha: process.env.PR_HEAD_SHA,
131+
});
132+
core.info(`Dismissed ${dismissed.length} superseded Ladon review(s).`);

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ jobs:
3737
# catches up.
3838
flags: >-
3939
-ignore ^unexpected\s+key\s+\x22queue\x22\s+for\s+\x22concurrency\x22\s+section
40+
- name: Test Ladon review-state helper
41+
run: node --test scripts/retire-superseded-ladon-reviews.test.cjs
4042
- name: Audit workflows
4143
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
4244
with:
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env node
2+
3+
const APPROVED = 'APPROVED';
4+
const CHANGES_REQUESTED = 'CHANGES_REQUESTED';
5+
6+
function normalizeLogin(login) {
7+
return String(login || '').toLowerCase();
8+
}
9+
10+
function latestBotReview(reviews, botLogin) {
11+
const normalizedBotLogin = normalizeLogin(botLogin);
12+
return reviews
13+
.filter((review) => normalizeLogin(review.user?.login) === normalizedBotLogin)
14+
.sort((left, right) => Number(left.id) - Number(right.id))
15+
.at(-1);
16+
}
17+
18+
function supersededChangeRequests(reviews, botLogin, headSha) {
19+
const latest = latestBotReview(reviews, botLogin);
20+
if (!latest || latest.state !== APPROVED || latest.commit_id !== headSha) {
21+
return [];
22+
}
23+
24+
const normalizedBotLogin = normalizeLogin(botLogin);
25+
return reviews.filter(
26+
(review) =>
27+
normalizeLogin(review.user?.login) === normalizedBotLogin &&
28+
review.state === CHANGES_REQUESTED &&
29+
Number(review.id) < Number(latest.id),
30+
);
31+
}
32+
33+
async function retireSupersededLadonReviews({
34+
github,
35+
owner,
36+
repo,
37+
pullNumber,
38+
botLogin,
39+
headSha,
40+
}) {
41+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
42+
owner,
43+
repo,
44+
pull_number: pullNumber,
45+
per_page: 100,
46+
});
47+
const superseded = supersededChangeRequests(reviews, botLogin, headSha);
48+
49+
for (const review of superseded) {
50+
await github.rest.pulls.dismissReview({
51+
owner,
52+
repo,
53+
pull_number: pullNumber,
54+
review_id: review.id,
55+
message: `Superseded by Ladon approval of ${headSha}.`,
56+
event: 'DISMISS',
57+
});
58+
}
59+
60+
return superseded.map((review) => review.id);
61+
}
62+
63+
module.exports = {
64+
latestBotReview,
65+
retireSupersededLadonReviews,
66+
supersededChangeRequests,
67+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env node
2+
3+
const assert = require('node:assert/strict');
4+
const test = require('node:test');
5+
6+
const {
7+
retireSupersededLadonReviews,
8+
supersededChangeRequests,
9+
} = require('./retire-superseded-ladon-reviews.cjs');
10+
11+
const BOT = 'aao-secretariat[bot]';
12+
const HEAD = 'new-head';
13+
14+
function review(id, state, commitId = HEAD, login = BOT) {
15+
return { id, state, commit_id: commitId, user: { login } };
16+
}
17+
18+
test('selects only the bot change requests superseded by its final head approval', () => {
19+
const reviews = [
20+
review(10, 'CHANGES_REQUESTED', 'old-head'),
21+
review(11, 'CHANGES_REQUESTED'),
22+
review(12, 'CHANGES_REQUESTED', 'old-head', 'human-reviewer'),
23+
review(13, 'DISMISSED', 'old-head'),
24+
review(14, 'APPROVED'),
25+
];
26+
27+
assert.deepEqual(
28+
supersededChangeRequests(reviews, 'AAO-SECRETARIAT[BOT]', HEAD).map(
29+
({ id }) => id,
30+
),
31+
[10, 11],
32+
);
33+
});
34+
35+
test('keeps change requests when the latest bot review is not an approval', () => {
36+
const reviews = [
37+
review(20, 'CHANGES_REQUESTED', 'old-head'),
38+
review(21, 'APPROVED'),
39+
review(22, 'COMMENTED'),
40+
];
41+
42+
assert.deepEqual(supersededChangeRequests(reviews, BOT, HEAD), []);
43+
});
44+
45+
test('keeps change requests when the latest approval targets an older head', () => {
46+
const reviews = [
47+
review(30, 'CHANGES_REQUESTED', 'older-head'),
48+
review(31, 'APPROVED', 'previous-head'),
49+
];
50+
51+
assert.deepEqual(supersededChangeRequests(reviews, BOT, HEAD), []);
52+
});
53+
54+
test('dismisses every selected review through the pull request API', async () => {
55+
const dismissed = [];
56+
const listReviews = Symbol('listReviews');
57+
const github = {
58+
paginate: async (method, params) => {
59+
assert.equal(method, listReviews);
60+
assert.deepEqual(params, {
61+
owner: 'adcontextprotocol',
62+
repo: 'adcp-client-python',
63+
pull_number: 1134,
64+
per_page: 100,
65+
});
66+
return [
67+
review(40, 'CHANGES_REQUESTED', 'old-head'),
68+
review(41, 'APPROVED'),
69+
];
70+
},
71+
rest: {
72+
pulls: {
73+
listReviews,
74+
dismissReview: async (params) => dismissed.push(params),
75+
},
76+
},
77+
};
78+
79+
const ids = await retireSupersededLadonReviews({
80+
github,
81+
owner: 'adcontextprotocol',
82+
repo: 'adcp-client-python',
83+
pullNumber: 1134,
84+
botLogin: BOT,
85+
headSha: HEAD,
86+
});
87+
88+
assert.deepEqual(ids, [40]);
89+
assert.deepEqual(dismissed, [
90+
{
91+
owner: 'adcontextprotocol',
92+
repo: 'adcp-client-python',
93+
pull_number: 1134,
94+
review_id: 40,
95+
message: `Superseded by Ladon approval of ${HEAD}.`,
96+
event: 'DISMISS',
97+
},
98+
]);
99+
});

0 commit comments

Comments
 (0)