Skip to content

ci: auto-close incomplete external PRs#4579

Merged
mmabrouk merged 1 commit into
mainfrom
ci/pr-contribution-check
Jun 8, 2026
Merged

ci: auto-close incomplete external PRs#4579
mmabrouk merged 1 commit into
mainfrom
ci/pr-contribution-check

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jun 8, 2026

Copy link
Copy Markdown
Member

Summary

External contributors increasingly open low-effort PRs that skip the template and ship no demo, which costs maintainer review time. This adds a bot that enforces our contribution requirements automatically.

A new pull_request_target workflow evaluates every PR from an external author (org members and bots are exempt via author_association, so no hardcoded handles). It fails a PR when:

  • the PR template is missing, emptied, or has an empty Summary, or
  • the diff touches functional code (SDK, API, or frontend) but the Demo section has no screenshot or video.

Files outside functional code (tests, docs, .github, lockfiles, images) are exempt, so a test-only or docs-only PR may mark Demo as N/A.

On failure the bot posts one sticky comment listing exactly what is missing, applies an incomplete-pr label, and closes the PR. If the contributor later fixes the description or adds a demo, the bot removes the label and reopens the PR automatically, so a genuine contributor never has to open a new one.

pull_request_target is used so the workflow has a write token even for fork PRs. It only reads PR metadata and posts a comment; it never checks out or runs the PR's code, so it is safe.

Testing

Verified locally

  • Both YAML files parse (yaml.safe_load): the workflow and .labels.yml.
  • Walked the script logic for the four cases: internal author skipped, empty/removed template, functional change without demo, compliant PR.
  • Note: pull_request_target and workflow_dispatch both read the workflow from the base branch, so live testing only works after this merges to main.

Added or updated tests

N/A. This is a CI workflow with no unit-test harness. It ships workflow_dispatch inputs (dry_run, force_external) so it can be exercised manually against a throwaway PR after merge.

QA follow-up

After merge: run the label sync once so incomplete-pr exists, then dispatch 13 - check PR contribution with dry_run=true and force_external=true against a crafted test PR to confirm the decision, before relying on it for real PRs.

Demo

N/A. This change only touches .github/** (CI configuration), which the workflow itself classifies as a non-functional change.

Checklist

  • I have included a video or screen recording for UI changes, or marked Demo as N/A
  • Relevant tests pass locally
  • Relevant linting and formatting pass locally
  • I have signed the CLA, or I will sign it when the bot prompts me

Add a pull_request_target workflow that checks PRs from external
contributors (org members and bots are exempt via author_association):
the PR template must be filled in, and any PR touching functional code
(SDK, API, or frontend) must include a demo recording. Non-compliant PRs
get a sticky comment, an incomplete-pr label, and are closed, then
auto-reopened once fixed. Adds workflow_dispatch dry_run/force_external
inputs for testing and registers the new label in .labels.yml.
@vercel

vercel Bot commented Jun 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jun 8, 2026 10:51am

Request Review

@dosubot dosubot Bot added size:XS This PR changes 0-9 lines, ignoring generated files. ci/cd labels Jun 8, 2026
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Chores
    • Added automated pull request validation workflow that checks template completeness and demo content for external contributors
    • Introduced new label to mark incomplete pull requests

Walkthrough

This PR introduces a new GitHub Actions workflow that enforces PR contribution requirements for external contributors. It adds an incomplete-pr label and a workflow (13-check-pr-contribution) that validates PR template completeness, requires demo content with media for PRs touching functional code, and automatically labels and closes non-compliant submissions.

Changes

PR Contribution Requirement Enforcement

Layer / File(s) Summary
Label definition and workflow setup
\.github/\.labels\.yml, \.github/workflows/13-check-pr-contribution\.yml
Adds the incomplete-pr label (color e11d21) and configures the workflow with pull_request_target and workflow_dispatch triggers, GitHub API permissions for commenting/labeling/closing, and per-PR concurrency with cancellation.
PR resolution and eligibility checks
\.github/workflows/13-check-pr-contribution\.yml
Resolves the target PR from event payload or manual pr_number input, skips enforcement for draft PRs unless manually triggered, and bypasses checks for bots and org-member authors unless force_external is enabled.
Requirement validation logic
\.github/workflows/13-check-pr-contribution\.yml
Parses PR description to extract and validate required template sections (Summary, Testing, Demo, Checklist), lists changed PR files via GitHub pagination, exempts test/docs/markdown/image files, and requires the Demo section to contain media (images, videos, links, or attachments) for PRs with functional changes.
Enforcement and comment management
\.github/workflows/13-check-pr-contribution\.yml
Implements an upsertComment helper that updates or creates comments with a fixed marker, applies the incomplete-pr label and closes non-compliant PRs with a detailed failure reason list, and removes the label and reopens compliant PRs while posting a success comment.

Sequence Diagram

sequenceDiagram
  participant GithubEvent as GitHub Event
  participant Workflow as PR Enforcement Workflow
  participant PRResolver as PR Resolver
  participant TemplateValidator as Template Validator
  participant FileAnalyzer as File Analyzer
  participant GithubAPI as GitHub API
  GithubEvent->>Workflow: pull_request_target or workflow_dispatch
  Workflow->>PRResolver: Resolve target PR
  PRResolver->>Workflow: PR object (or skip if draft/internal)
  Workflow->>TemplateValidator: Parse body for sections
  TemplateValidator->>Workflow: Missing sections list
  Workflow->>FileAnalyzer: List changed files
  FileAnalyzer->>Workflow: Functional changes detected
  alt Requirements Pass
    Workflow->>GithubAPI: Remove incomplete-pr label
    Workflow->>GithubAPI: Reopen if closed
    Workflow->>GithubAPI: Upsert success comment
  else Requirements Fail
    Workflow->>GithubAPI: Add incomplete-pr label
    Workflow->>GithubAPI: Upsert failure comment with reasons
    Workflow->>GithubAPI: Close PR
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'ci: auto-close incomplete external PRs' clearly and concisely summarizes the main change: a CI workflow that auto-closes incomplete PRs from external contributors.
Description check ✅ Passed The description comprehensively explains the PR's purpose, implementation details, testing approach, and QA follow-up, all directly related to the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/pr-contribution-check

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d52cebdd-3b3c-4a66-8454-11db7e933c27

📥 Commits

Reviewing files that changed from the base of the PR and between 98b8a9d and 5af30bf.

📒 Files selected for processing (2)
  • .github/.labels.yml
  • .github/workflows/13-check-pr-contribution.yml

Comment on lines +76 to +77
/https?:\/\/[^\s)]*\/user-attachments\//i, // GitHub uploads
/https?:\/\/[^\s)]*githubusercontent\.com\//i,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten the generic githubusercontent media match.

Line 77 accepts any *.githubusercontent.com URL, so a raw link to a text or source file can satisfy the demo gate without an actual screenshot or video. That makes the policy easy to bypass.

Suggested fix
             const MEDIA = [
               /!\[[^\]]*\]\([^)]+\)/,                                  // markdown image
               /<img\s/i,
               /<video\s/i,
               /https?:\/\/[^\s)]+\.(mp4|mov|webm|gif)/i,               // direct video/gif
               /https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//i,
               /https?:\/\/(www\.)?loom\.com\//i,
               /https?:\/\/[^\s)]*\/user-attachments\//i,               // GitHub uploads
-              /https?:\/\/[^\s)]*githubusercontent\.com\//i,
             ];

Comment on lines +142 to +150
async function upsertComment(text) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body: text });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only upsert the bot's own sticky comment.

Line 146 picks the first comment containing MARKER without checking the author. Because the marker is public, a contributor can quote or copy it and this helper will target that human comment on the next run. Best case the update fails and enforcement stops; worst case it overwrites user discussion.

Suggested fix
             async function upsertComment(text) {
               const comments = await github.paginate(github.rest.issues.listComments, {
                 owner, repo, issue_number: number, per_page: 100,
               });
-              const existing = comments.find((c) => c.body && c.body.includes(MARKER));
+              const existing = comments.find(
+                (c) =>
+                  c.user?.login === 'github-actions[bot]' &&
+                  c.body?.startsWith(MARKER)
+              );
               if (existing) {
                 await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
               } else {
                 await github.rest.issues.createComment({ owner, repo, issue_number: number, body: text });
               }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function upsertComment(text) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body: text });
async function upsertComment(text) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
const existing = comments.find(
(c) =>
c.user?.login === 'github-actions[bot]' &&
c.body?.startsWith(MARKER)
);
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: text });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body: text });
}

Comment on lines +177 to +203
await upsertComment(text);
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
} catch (e) {
core.warning(`Could not add label ${LABEL}: ${e.message}`);
}
if (pr.state === 'open') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' });
}
core.notice(`Closed PR #${number}:\n${list}`);
} else {
if (dryRun) {
core.info(`[dry-run] PR #${number} meets contribution requirements.`);
return;
}
// Compliant. If the bot had previously closed it, reopen and clear the flag.
const labels = (pr.labels || []).map((l) => l.name);
if (labels.includes(LABEL)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
} catch (e) {
core.warning(`Could not remove label ${LABEL}: ${e.message}`);
}
if (pr.state === 'closed') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'open' });
}
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.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't close the PR unless the reopen signal was recorded.

This path swallows addLabels failures but still closes the PR, while the compliant path only reopens when incomplete-pr is present. Because label creation is a separate rollout step here, or if addLabels fails transiently, the bot can strand a fixed PR in the closed state with no automatic recovery.

Suggested fix
               await upsertComment(text);
+              let labeled = false;
               try {
                 await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
+                labeled = true;
               } catch (e) {
                 core.warning(`Could not add label ${LABEL}: ${e.message}`);
               }
+              if (!labeled) {
+                core.setFailed(`Refusing to close PR #${number} because label ${LABEL} could not be applied.`);
+                return;
+              }
               if (pr.state === 'open') {
                 await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' });
               }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await upsertComment(text);
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
} catch (e) {
core.warning(`Could not add label ${LABEL}: ${e.message}`);
}
if (pr.state === 'open') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' });
}
core.notice(`Closed PR #${number}:\n${list}`);
} else {
if (dryRun) {
core.info(`[dry-run] PR #${number} meets contribution requirements.`);
return;
}
// Compliant. If the bot had previously closed it, reopen and clear the flag.
const labels = (pr.labels || []).map((l) => l.name);
if (labels.includes(LABEL)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
} catch (e) {
core.warning(`Could not remove label ${LABEL}: ${e.message}`);
}
if (pr.state === 'closed') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'open' });
}
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.`);
await upsertComment(text);
let labeled = false;
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [LABEL] });
labeled = true;
} catch (e) {
core.warning(`Could not add label ${LABEL}: ${e.message}`);
}
if (!labeled) {
core.setFailed(`Refusing to close PR #${number} because label ${LABEL} could not be applied.`);
return;
}
if (pr.state === 'open') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'closed' });
}
core.notice(`Closed PR #${number}:\n${list}`);
} else {
if (dryRun) {
core.info(`[dry-run] PR #${number} meets contribution requirements.`);
return;
}
// Compliant. If the bot had previously closed it, reopen and clear the flag.
const labels = (pr.labels || []).map((l) => l.name);
if (labels.includes(LABEL)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: LABEL });
} catch (e) {
core.warning(`Could not remove label ${LABEL}: ${e.message}`);
}
if (pr.state === 'closed') {
await github.rest.pulls.update({ owner, repo, pull_number: number, state: 'open' });
}
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.`);

@mmabrouk mmabrouk merged commit a81e5c9 into main Jun 8, 2026
8 checks passed
@ashrafchowdury

Copy link
Copy Markdown
Contributor

💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size:XS This PR changes 0-9 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants