Skip to content

Commit 52d6389

Browse files
authored
Merge pull request #4581 from Agenta-AI/ci/pr-contribution-relax
ci: stop PR bot over-closing on missing checklist + fix reopen loop
2 parents 4d9b1c0 + a2dc2f3 commit 52d6389

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

.github/.labels.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@
109109
- name: invalid
110110
description: This doesn't seem right
111111
color: e4e669
112+
- name: incomplete-pr
113+
description: PR is missing required template sections or a demo recording
114+
color: e11d21
112115
- name: performance
113116
description: A code change that improves performance
114117
color: f3ffb2
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
name: "13 - check PR contribution"
2+
3+
# Enforces the contribution requirements on pull requests opened by external
4+
# contributors: the PR template must be filled in, and any PR that changes
5+
# functional code (SDK, API, or frontend) must include a demo recording or
6+
# screenshot. PRs that do not comply are commented on, labelled, and closed.
7+
#
8+
# Uses pull_request_target so the workflow has a write token even for fork PRs.
9+
# It only reads PR metadata (body and changed-file list) and posts a comment.
10+
# It never checks out or runs the PR's code, so this is safe.
11+
12+
on:
13+
# No 'reopened': a maintainer who manually reopens a flagged PR should win,
14+
# otherwise the reopen event would immediately re-close it. Auto-reopen on a
15+
# fixed description still works through 'edited' and 'synchronize'.
16+
pull_request_target:
17+
types: [opened, edited, synchronize, ready_for_review]
18+
workflow_dispatch:
19+
inputs:
20+
pr_number:
21+
description: "PR number to evaluate"
22+
required: true
23+
type: string
24+
dry_run:
25+
description: "Only log the decision; do not comment, label, close, or reopen"
26+
required: false
27+
default: true
28+
type: boolean
29+
force_external:
30+
description: "Ignore the org-membership exemption (treat the author as external)"
31+
required: false
32+
default: false
33+
type: boolean
34+
35+
permissions:
36+
contents: read
37+
pull-requests: write
38+
issues: write
39+
40+
concurrency:
41+
group: pr-contribution-${{ github.event.pull_request.number || inputs.pr_number }}
42+
cancel-in-progress: true
43+
44+
jobs:
45+
check:
46+
name: Check contribution requirements
47+
runs-on: ubuntu-latest
48+
steps:
49+
- name: Evaluate PR
50+
uses: actions/github-script@v7
51+
with:
52+
script: |
53+
const MARKER = '<!-- agenta-pr-contribution-check -->';
54+
const LABEL = 'incomplete-pr';
55+
const INTERNAL = ['OWNER', 'MEMBER', 'COLLABORATOR'];
56+
57+
// Files that are non-functional. A PR touching only these may skip the demo.
58+
const EXEMPT = [
59+
/(^|\/)tests?\//i,
60+
/(^|\/)__tests__\//i,
61+
/(^|\/)test_[^/]*\.py$/i,
62+
/_test\.py$/i,
63+
/\.(test|spec)\.[jt]sx?$/i,
64+
/^docs\//i,
65+
/\.mdx?$/i,
66+
/^\.github\//i,
67+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|uv\.lock|Cargo\.lock)$/i,
68+
/\.(png|jpe?g|gif|svg|webp|ico)$/i,
69+
];
70+
71+
// What counts as a demo in the Demo section.
72+
const MEDIA = [
73+
/!\[[^\]]*\]\([^)]+\)/, // markdown image
74+
/<img\s/i,
75+
/<video\s/i,
76+
/https?:\/\/[^\s)]+\.(mp4|mov|webm|gif)/i, // direct video/gif
77+
/https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i,
78+
/https?:\/\/(www\.)?loom\.com\//i,
79+
/https?:\/\/[^\s)]*\/user-attachments\//i, // GitHub uploads
80+
/https?:\/\/[^\s)]*githubusercontent\.com\//i,
81+
];
82+
83+
const owner = context.repo.owner;
84+
const repo = context.repo.repo;
85+
const dispatch = context.eventName === 'workflow_dispatch';
86+
const inputs = context.payload.inputs || {};
87+
const dryRun = dispatch && String(inputs.dry_run) === 'true';
88+
const forceExternal = dispatch && String(inputs.force_external) === 'true';
89+
90+
// Resolve the PR from the event payload, or fetch it for manual dispatch.
91+
let pr;
92+
if (dispatch) {
93+
const num = Number(inputs.pr_number);
94+
pr = (await github.rest.pulls.get({ owner, repo, pull_number: num })).data;
95+
} else {
96+
pr = context.payload.pull_request;
97+
}
98+
const number = pr.number;
99+
100+
// Drafts are work in progress; only enforce on ready PRs (or manual runs).
101+
if (!dispatch && pr.draft) {
102+
core.info(`PR #${number} is a draft, skipping.`);
103+
return;
104+
}
105+
106+
// Exempt internal contributors (org members) and bots.
107+
if (!forceExternal && (pr.user.type === 'Bot' || INTERNAL.includes(pr.author_association))) {
108+
core.info(`PR #${number} by ${pr.user.login} (${pr.author_association}) is internal, skipping.`);
109+
return;
110+
}
111+
112+
const body = pr.body || '';
113+
114+
function section(name) {
115+
const re = new RegExp('##\\s*' + name + '\\b([\\s\\S]*?)(?=\\n##\\s|$)', 'i');
116+
const m = body.match(re);
117+
return m ? m[1].replace(/<!--[\s\S]*?-->/g, '').trim() : null;
118+
}
119+
120+
const reasons = [];
121+
122+
// 1) The PR is described. We only require a non-empty Summary, not the
123+
// full template. Missing Testing/Checklist sections do not close a PR;
124+
// a thorough PR with a demo should never be closed over a checklist.
125+
if (!body.trim()) {
126+
reasons.push('The pull request description is empty. Please fill in the PR template.');
127+
} else if (!section('Summary')) {
128+
reasons.push('The **Summary** section is missing or empty. Describe what changed and why using the PR template.');
129+
}
130+
131+
// 2) Demo is present for functional changes. Scan the whole body, not
132+
// just the Demo section, so a screenshot or video placed anywhere counts.
133+
const files = await github.paginate(github.rest.pulls.listFiles, {
134+
owner, repo, pull_number: number, per_page: 100,
135+
});
136+
const functional = files.some((f) => !EXEMPT.some((r) => r.test(f.filename)));
137+
const hasMedia = MEDIA.some((r) => r.test(body));
138+
if (functional && !hasMedia) {
139+
reasons.push('This PR changes functional code (SDK, API, or frontend) but includes no demo. Add a screenshot or short video of the change. Only test-only, docs-only, or chore changes may skip it.');
140+
}
141+
142+
async function upsertComment(text) {
143+
const comments = await github.paginate(github.rest.issues.listComments, {
144+
owner, repo, issue_number: number, per_page: 100,
145+
});
146+
const existing = comments.find((c) => c.body && c.body.includes(MARKER));
147+
if (existing) {
148+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
149+
} else {
150+
await github.rest.issues.createComment({ owner, repo, issue_number: number, body: text });
151+
}
152+
}
153+
154+
if (reasons.length) {
155+
const list = reasons.map((r) => '- ' + r).join('\n');
156+
const text = [
157+
MARKER,
158+
`Hi @${pr.user.login}, thanks for opening a pull request. 🙏`,
159+
'',
160+
'This PR was **automatically closed** because it does not yet meet our contribution requirements:',
161+
'',
162+
list,
163+
'',
164+
'We ask for this so every change is documented and demonstrably tested before review.',
165+
'',
166+
'**How to get it reopened**',
167+
'Update the PR description (and add a demo recording if your change touches functional code). The bot reopens the PR automatically once the requirements are met. No need to open a new one.',
168+
'',
169+
'See the [Contributing guide](https://agenta.ai/docs/contributing/overview) and [Creating your first PR](https://agenta.ai/docs/contributing/first-pr). If you think this was closed in error, leave a comment and a maintainer will take a look.',
170+
].join('\n');
171+
172+
if (dryRun) {
173+
core.notice(`[dry-run] Would close PR #${number}:\n${list}`);
174+
return;
175+
}
176+
177+
await upsertComment(text);
178+
try {
179+
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
180+
} catch (e) {
181+
core.warning(`Could not add label ${LABEL}: ${e.message}`);
182+
}
183+
if (pr.state === 'open') {
184+
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' });
185+
}
186+
core.notice(`Closed PR #${number}:\n${list}`);
187+
} else {
188+
if (dryRun) {
189+
core.info(`[dry-run] PR #${number} meets contribution requirements.`);
190+
return;
191+
}
192+
// Compliant. If the bot had previously closed it, reopen and clear the flag.
193+
const labels = (pr.labels || []).map((l) => l.name);
194+
if (labels.includes(LABEL)) {
195+
try {
196+
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
197+
} catch (e) {
198+
core.warning(`Could not remove label ${LABEL}: ${e.message}`);
199+
}
200+
if (pr.state === 'closed') {
201+
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'open' });
202+
}
203+
await upsertComment(`${MARKER}\n✅ Thanks @${pr.user.login}! This PR now meets the contribution requirements and has been reopened. A maintainer will review it soon.`);
204+
}
205+
core.info(`PR #${number} meets contribution requirements.`);
206+
}

0 commit comments

Comments
 (0)