Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions .github/scripts/bot-pr-draft-explainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* PR Draft Explainer Bot
*
* Triggers when a pull request is converted to draft.
*
* Safety:
* - Prevents duplicate comments using a unique HTML marker.
* - Only posts if a CHANGES_REQUESTED review exists.
* - Fails safely if review lookup fails.
* - Uses pagination to scan existing comments safely.
*/

const COMMENT_MARKER = "<!-- pr-draft-explainer -->";
const DRY_RUN = process.env.DRY_RUN === "true";
const manualPRNumber = process.env.PR_NUMBER;

/**
* Checks if the reminder comment already exists.
* Uses GitHub pagination to safely scan all comments.
*
* @param {import("@actions/github").GitHub} params.github - Authenticated GitHub client.
* @param {string} params.owner - Repository owner.
* @param {string} params.repo - Repository name.
* @param {number} params.issueNumber - Pull request number.
* @param {string} params.marker - Unique marker string to detect duplicate comments.
* @returns {Promise<boolean>} - True if a comment with the marker exists.
*/

async function commentExists({ github, owner, repo, issueNumber, marker }) {
console.log("Checking for existing explanation comments...");

let scanned = 0;
const MAX_COMMENTS = 500;

for await (const response of github.paginate.iterator(
github.rest.issues.listComments,
{
owner,
repo,
issue_number: issueNumber,
per_page: 100,
}
)) {
for (const comment of response.data) {
scanned++;
if (comment.body?.includes(marker)) {
console.log(`Found existing explanation comment (scanned ${scanned} comments).`);
return true;
}
if (scanned >= MAX_COMMENTS) {
console.log(`Reached scan limit (${MAX_COMMENTS} comments) — assuming no duplicate.`);
return false;
}
}
}

console.log(`No existing explainer comment found (scanned ${scanned} comments).`);
return false;
}

/**
* Builds the draft explainer comment body.
*
* @param {string} greetingTarget - Formatted username to greet (e.g., "@username").
* @returns {string} - Formatted reminder message.
*/

function buildExplainerComment(greetingTarget) {
return `
${COMMENT_MARKER}
Hi ${greetingTarget}!

We suggested a few updates and moved this PR to **draft** while you apply the feedback. This keeps it out of the review queue until it is ready again.

### What happens next?
- Make the requested changes.
- When you are ready, click **“Ready for review”** (recommended) or use the \`/review\` command.

Thanks again for your contribution!
`.trim();
}

/**
* Main entry point.
*
* Execution Flow:
* 1. Ensure PR exists in event payload.
* 2. Prevent duplicate bot comments.
* 3. Confirm at least one CHANGES_REQUESTED review exists.
* 4. Post explanation comment.
*/

module.exports = async ({ github, context }) => {
let pr = context.payload.pull_request;
let prNumber = pr?.number || manualPRNumber;
const { owner, repo } = context.repo;

if (!prNumber) {
console.log("No PR number found in payload or environment. Exiting.");
return;
}
Comment thread
parvninama marked this conversation as resolved.
if (!pr) {
console.log(`Fetching PR #${prNumber} (manual workflow_dispatch run)...`);

try {
const prResponse = await github.rest.pulls.get({
owner,
repo,
pull_number: prNumber,
});

pr = prResponse.data;
} catch (error) {
console.log(`Failed to fetch PR #${prNumber}: ${error.message}`);
return;
}
}

const authorLogin = pr.user?.login;
const greetingTarget = authorLogin ? `@${authorLogin}` : "there";

if (!pr.draft) {
console.log(`PR #${prNumber} is not draft. Skipping.`);
return;
}
Comment thread
parvninama marked this conversation as resolved.

console.log(`PR #${prNumber} was converted to draft. Checking if explanation is needed.`);

// Prevent duplicate comments
let alreadyCommented = false;
try {
alreadyCommented = await commentExists({
github,
owner,
repo,
issueNumber: prNumber,
marker: COMMENT_MARKER,
});
} catch (err) {
console.log(`Failed to check existing comments on PR #${prNumber} in ${owner}/${repo}: ${err.message}`);
console.log("Skipping explanation to avoid potential duplicate.");
return;
}

if (alreadyCommented) {
console.log("Explanation already exists — skipping.");
return;
}

// Only proceed if changes were previously requested on this PR
try {
const reviews = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
});

const hasChangeRequest = reviews.data.some(
Comment thread
parvninama marked this conversation as resolved.
Outdated
(review) => review.state === "CHANGES_REQUESTED",
);

if (!hasChangeRequest) {
console.log("No CHANGES_REQUESTED review found. Skipping explanation comment.");
return;
}
Comment thread
parvninama marked this conversation as resolved.

} catch (error) {
console.log(`Review lookup failed for PR #${prNumber}: ${error.message}. Skipping to avoid a false explanation.`);
return;
}

// Post explanation comment
try {
if (DRY_RUN) {
console.log(`[DRY RUN] Explanation comment would be posted on PR #${prNumber}.`);
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: buildExplainerComment(greetingTarget),
});
console.log(`Posted draft explanation comment on PR #${prNumber}.`);
} catch (error) {
console.log(`Failed to post draft explanation on PR #${prNumber}: ${error.message}`);
}
};
50 changes: 50 additions & 0 deletions .github/workflows/bot-pr-draft-explainer.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# This workflow posts a friendly explanation when a PR is moved to draft status after changes are requested

name: PR Draft Explainer
on:
pull_request:
types: [converted_to_draft]
workflow_dispatch:
inputs:
pr_number:
description: "PR number to test"
required: true
type: number
dry_run:
description: "Run without posting comment"
required: false
type: boolean
default: true

permissions:
pull-requests: read
issues: write
contents: read

jobs:
pr-draft-explainer:
runs-on: ubuntu-latest
concurrency:
group: pr-draft-explainer-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
cancel-in-progress: true
steps:
- name: Harden the runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit

- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main

- name: Run draft explainer bot
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd #v8.0.0
with:
script: |
const script = require('./.github/scripts/bot-pr-draft-explainer.js');
await script({ github, context });
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
### Docs

### .github

- Added PR draft explainer workflow to comment when PRs are converted to draft after changes are requested. (#1723)

## [0.2.1] - 2026-03-05

Expand Down