Skip to content

Commit dcbc216

Browse files
committed
feat: add Codex LGTM gate and auto-merge workflow for OpenClaw PRs
1 parent 502654b commit dcbc216

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

.github/workflows/auto-merge.yml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Auto-Merge on Codex + Human LGTM
2+
3+
# Triggers when a PR review is submitted.
4+
# Merges if ALL of the following are true:
5+
# 1. PR has the auto-fix label (from OpenClaw)
6+
# 2. commit status codex/lgtm = success (set by codex-gate.yml)
7+
# 3. At least one human collaborator (not FuugaMo/Codex) has APPROVED
8+
9+
on:
10+
pull_request_review:
11+
types: [submitted]
12+
status:
13+
# Re-check when the codex/lgtm status changes
14+
# (handles the case where human approved before Codex finished)
15+
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
statuses: read
20+
21+
jobs:
22+
merge-check:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/github-script@v7
26+
with:
27+
github-token: ${{ secrets.GITHUB_TOKEN }}
28+
script: |
29+
const CODEX_USER = 'FuugaMo';
30+
31+
// ── Find target PR ─────────────────────────────────────────────────
32+
let pr;
33+
if (context.eventName === 'pull_request_review') {
34+
pr = context.payload.pull_request;
35+
} else {
36+
// status event — find open auto-fix PRs for this commit
37+
const sha = context.payload.sha;
38+
const { data: prs } = await github.rest.pulls.list({
39+
owner: context.repo.owner,
40+
repo: context.repo.repo,
41+
state: 'open',
42+
});
43+
pr = prs.find(p =>
44+
p.head.sha === sha &&
45+
p.labels.some(l => l.name === 'auto-fix')
46+
);
47+
if (!pr) return;
48+
}
49+
50+
const labels = (pr.labels || []).map(l => l.name);
51+
if (!labels.includes('auto-fix')) return;
52+
if (pr.draft) return;
53+
54+
// ── Check codex/lgtm commit status ────────────────────────────────
55+
const { data: statuses } = await github.rest.repos.listCommitStatusesForRef({
56+
owner: context.repo.owner,
57+
repo: context.repo.repo,
58+
ref: pr.head.sha,
59+
});
60+
61+
const codexStatus = statuses.find(s => s.context === 'codex/lgtm');
62+
if (!codexStatus || codexStatus.state !== 'success') {
63+
console.log('codex/lgtm not yet success — skipping merge');
64+
return;
65+
}
66+
67+
// ── Check human approval ───────────────────────────────────────────
68+
const { data: reviews } = await github.rest.pulls.listReviews({
69+
owner: context.repo.owner,
70+
repo: context.repo.repo,
71+
pull_number: pr.number,
72+
});
73+
74+
// Get the latest review state per reviewer
75+
const latest = new Map();
76+
for (const r of reviews) {
77+
if (r.state === 'COMMENTED') continue;
78+
latest.set(r.user.login, r.state);
79+
}
80+
81+
const humanApproved = [...latest.entries()].some(
82+
([user, state]) => user !== CODEX_USER && state === 'APPROVED'
83+
);
84+
85+
if (!humanApproved) {
86+
console.log('No human approval yet — skipping merge');
87+
return;
88+
}
89+
90+
// ── All conditions met — merge ─────────────────────────────────────
91+
console.log(`Merging PR #${pr.number}: Codex LGTM ✓ + human approval ✓`);
92+
93+
await github.rest.pulls.merge({
94+
owner: context.repo.owner,
95+
repo: context.repo.repo,
96+
pull_number: pr.number,
97+
merge_method: 'squash',
98+
commit_title: `${pr.title} (#${pr.number})`,
99+
commit_message: [
100+
pr.body || '',
101+
'',
102+
`Auto-merged: Codex LGTM + human approval`,
103+
].join('\n').trim(),
104+
});

.github/workflows/codex-gate.yml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: Codex Review Gate
2+
3+
# Watches for Codex reviews (submitted via FuugaMo's GitHub account).
4+
# Sets the commit status codex/lgtm based on the review outcome.
5+
# Also handles comment-based signals in case Codex posts a comment
6+
# rather than a formal review state.
7+
8+
on:
9+
pull_request_review:
10+
types: [submitted, dismissed]
11+
issue_comment:
12+
types: [created, edited]
13+
14+
permissions:
15+
statuses: write
16+
pull-requests: read
17+
18+
jobs:
19+
gate:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/github-script@v7
23+
with:
24+
github-token: ${{ secrets.GITHUB_TOKEN }}
25+
script: |
26+
const CODEX_USER = 'FuugaMo';
27+
28+
// ── Resolve PR and commit SHA ──────────────────────────────────────
29+
let pr, sha;
30+
31+
if (context.eventName === 'pull_request_review') {
32+
pr = context.payload.pull_request;
33+
sha = pr.head.sha;
34+
} else {
35+
// issue_comment — only handle PR comments
36+
if (!context.payload.issue.pull_request) return;
37+
const { data } = await github.rest.pulls.get({
38+
owner: context.repo.owner,
39+
repo: context.repo.repo,
40+
pull_number: context.payload.issue.number,
41+
});
42+
pr = data;
43+
sha = data.head.sha;
44+
}
45+
46+
// Only process PRs with the auto-fix label (from OpenClaw)
47+
const labels = (pr.labels || []).map(l => l.name);
48+
if (!labels.includes('auto-fix')) return;
49+
50+
// ── Determine Codex verdict ────────────────────────────────────────
51+
let state = null;
52+
let description = '';
53+
54+
if (context.eventName === 'pull_request_review') {
55+
const review = context.payload.review;
56+
if (review.user.login !== CODEX_USER) return;
57+
58+
if (review.state === 'APPROVED') {
59+
state = 'success';
60+
description = 'Codex LGTM — no critical issues';
61+
} else if (review.state === 'CHANGES_REQUESTED') {
62+
state = 'failure';
63+
description = 'Codex requested changes';
64+
} else if (review.state === 'DISMISSED') {
65+
state = 'pending';
66+
description = 'Codex review dismissed — awaiting re-review';
67+
} else {
68+
// COMMENTED — parse the body for signals
69+
const body = (review.body || '').toLowerCase();
70+
const critical = ['critical', 'p0', 'security issue', 'breaking', 'incorrect'];
71+
const positive = ['lgtm', 'no issues', 'looks good', 'no critical', 'approved'];
72+
if (critical.some(k => body.includes(k))) {
73+
state = 'failure';
74+
description = 'Codex flagged critical issues';
75+
} else if (positive.some(k => body.includes(k))) {
76+
state = 'success';
77+
description = 'Codex LGTM (via comment)';
78+
}
79+
}
80+
} else {
81+
// issue_comment path — only care about comments from Codex/FuugaMo
82+
const comment = context.payload.comment;
83+
if (comment.user.login !== CODEX_USER) return;
84+
const body = (comment.body || '').toLowerCase();
85+
const critical = ['critical', 'p0', 'security issue', 'breaking', 'incorrect'];
86+
const positive = ['lgtm', 'no issues', 'looks good', 'no critical', 'approved'];
87+
if (critical.some(k => body.includes(k))) {
88+
state = 'failure';
89+
description = 'Codex flagged critical issues';
90+
} else if (positive.some(k => body.includes(k))) {
91+
state = 'success';
92+
description = 'Codex LGTM (via comment)';
93+
}
94+
}
95+
96+
if (!state) return; // not enough signal to set a status
97+
98+
// ── Set commit status ──────────────────────────────────────────────
99+
await github.rest.repos.createCommitStatus({
100+
owner: context.repo.owner,
101+
repo: context.repo.repo,
102+
sha,
103+
state,
104+
description,
105+
context: 'codex/lgtm',
106+
});
107+
108+
// ── Enable auto-merge on the PR when Codex approves ───────────────
109+
if (state === 'success') {
110+
try {
111+
await github.rest.pulls.updateBranch({
112+
owner: context.repo.owner,
113+
repo: context.repo.repo,
114+
pull_number: pr.number,
115+
});
116+
} catch (_) {}
117+
118+
// Enable squash auto-merge (GitHub native)
119+
await github.graphql(`
120+
mutation($prId: ID!) {
121+
enablePullRequestAutoMerge(input: {
122+
pullRequestId: $prId,
123+
mergeMethod: SQUASH
124+
}) { clientMutationId }
125+
}
126+
`, { prId: pr.node_id });
127+
}

0 commit comments

Comments
 (0)