From 30df298c1ecc081324edd6f028a8d842b8553296 Mon Sep 17 00:00:00 2001 From: GCHQDeveloper581 <63102987+GCHQDeveloper581@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:38:54 +0000 Subject: [PATCH 1/2] Add initial code for autoclosing PRs with no CLA --- .github/workflows/cla-close-stale.yml | 144 ++++++++++++++++++++++++++ .github/workflows/cla-label.yml | 87 ++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 .github/workflows/cla-close-stale.yml create mode 100644 .github/workflows/cla-label.yml diff --git a/.github/workflows/cla-close-stale.yml b/.github/workflows/cla-close-stale.yml new file mode 100644 index 0000000000..10270c7da1 --- /dev/null +++ b/.github/workflows/cla-close-stale.yml @@ -0,0 +1,144 @@ +name: CLA Stale PR Closer + +on: + schedule: + # Runs daily at 02:00 UTC. Adjust as required. + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log actions without applying them' + type: boolean + default: false + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + close-stale: + runs-on: ubuntu-latest + env: + AWAITING_LABEL: 'awaiting cla' + + # --- Configurable intervals (in days) --- + # interval 1: days of inactivity before the warning comment is posted + WARN_AFTER_DAYS: '14' + # interval 2: days after the warning before the PR is closed + CLOSE_AFTER_DAYS: '14' + + # --- Detection config (keep in sync with cla-label.yml) --- + CLA_BOT_LOGINS: 'CLAassistant,cla-assistant[bot],github-actions[bot]' + NOT_SIGNED_REGEX: 'have not signed|has not signed|please sign|we need.*sign|\[ \]' + SIGNED_REGEX: 'all committers have signed|all contributors have signed|has signed the cla|have signed the cla' + + # A unique marker so we only post the warning once and can find it later. + WARN_MARKER: '' + CLOSE_MARKER: '' + + steps: + - name: Process open PRs awaiting CLA + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const AWAITING_LABEL = process.env.AWAITING_LABEL; + const WARN_AFTER_DAYS = parseInt(process.env.WARN_AFTER_DAYS, 10); + const CLOSE_AFTER_DAYS = parseInt(process.env.CLOSE_AFTER_DAYS, 10); + const WARN_MARKER = process.env.WARN_MARKER; + const CLOSE_MARKER = process.env.CLOSE_MARKER; + const dryRun = context.payload.inputs?.dry_run === 'true' + || context.payload.inputs?.dry_run === true; + + const botLogins = process.env.CLA_BOT_LOGINS.split(',').map(s => s.trim().toLowerCase()); + const notSigned = new RegExp(process.env.NOT_SIGNED_REGEX, 'i'); + const signed = new RegExp(process.env.SIGNED_REGEX, 'i'); + + const DAY_MS = 24 * 60 * 60 * 1000; + const now = Date.now(); + + const daysBetween = (fromIso) => (now - new Date(fromIso).getTime()) / DAY_MS; + + // Fetch all open PRs. (We check the label per-PR to keep it robust + // even if the label sync workflow lagged.) + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', per_page: 100, + }); + + core.info(`Scanning ${prs.length} open PR(s). dryRun=${dryRun}`); + + for (const pr of prs) { + const num = pr.number; + + const hasLabel = pr.labels.some(l => + (typeof l === 'string' ? l : l.name) === AWAITING_LABEL + ); + if (!hasLabel) continue; + + // Load comments to (a) re-confirm CLA state and (b) find our marker. + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: num, per_page: 100, + }); + + // Re-confirm the CLA is genuinely still unsigned, to avoid closing + // a PR where the label sync simply hasn't caught up. + const claComments = comments.filter(c => + botLogins.includes((c.user?.login || '').toLowerCase()) && + (notSigned.test(c.body) || signed.test(c.body)) + ); + if (claComments.length > 0) { + const latest = claComments[claComments.length - 1]; + const isSigned = signed.test(latest.body) && !notSigned.test(latest.body); + if (isSigned) { + core.info(`PR #${num}: CLA appears signed; skipping (label sync will remove it).`); + continue; + } + } + + // Find our previously posted warning, if any. + const warnComment = comments.find(c => (c.body || '').includes(WARN_MARKER)); + + if (!warnComment) { + // No warning yet — check whether we've passed interval 1. + // "Age" is measured from the PR creation date. Swap for + // pr.updated_at if you prefer inactivity-based timing. + const ageDays = daysBetween(pr.created_at); + if (ageDays >= WARN_AFTER_DAYS) { + const body = + `${WARN_MARKER}\n` + + `⚠️ This PR will be automatically closed if the CLA is not ` + + `signed within ${CLOSE_AFTER_DAYS} days.`; + core.info(`PR #${num}: posting warning (age ${ageDays.toFixed(1)}d).`); + if (!dryRun) { + await github.rest.issues.createComment({ + owner, repo, issue_number: num, body, + }); + } + } else { + core.info(`PR #${num}: below warn threshold (age ${ageDays.toFixed(1)}d).`); + } + continue; + } + + // Warning already posted — check whether interval 2 has elapsed. + const sinceWarnDays = daysBetween(warnComment.created_at); + if (sinceWarnDays >= CLOSE_AFTER_DAYS) { + core.info(`PR #${num}: closing (warned ${sinceWarnDays.toFixed(1)}d ago).`); + if (!dryRun) { + await github.rest.issues.createComment({ + owner, repo, issue_number: num, + body: + `${CLOSE_MARKER}\n` + + `This PR has been automatically closed as the CLA has not ` + + `been signed. We are happy to have it reopened if the CLA ` + + `is signed subsequently.`, + }); + await github.rest.pulls.update({ + owner, repo, pull_number: num, state: 'closed', + }); + } + } else { + core.info(`PR #${num}: warned but grace period not elapsed (${sinceWarnDays.toFixed(1)}/${CLOSE_AFTER_DAYS}d).`); + } + } diff --git a/.github/workflows/cla-label.yml b/.github/workflows/cla-label.yml new file mode 100644 index 0000000000..48ca10d875 --- /dev/null +++ b/.github/workflows/cla-label.yml @@ -0,0 +1,87 @@ +name: CLA Label Sync + +on: + issue_comment: + types: [created, edited] + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + sync-label: + # Only run for PRs (issue_comment fires for issues too) + if: >- + github.event_name == 'pull_request_target' || + (github.event.issue.pull_request != null) + runs-on: ubuntu-latest + steps: + - name: Sync "awaiting cla" label + uses: actions/github-script@v7 + env: + AWAITING_LABEL: 'awaiting cla' + # Bot login that posts the CLA comment. Common values: + # 'github-actions[bot]', 'CLAassistant', 'cla-assistant[bot]' + CLA_BOT_LOGINS: 'CLAassistant,cla-assistant[bot],github-actions[bot]' + # Regex (case-insensitive) that matches an UNSIGNED CLA comment + NOT_SIGNED_REGEX: 'have not signed|has not signed|please sign|we need.*sign|\[ \]' + # Regex (case-insensitive) that matches a SIGNED CLA comment + SIGNED_REGEX: 'all committers have signed|all contributors have signed|has signed the cla|have signed the cla' + with: + script: | + const { AWAITING_LABEL } = process.env; + const botLogins = process.env.CLA_BOT_LOGINS.split(',').map(s => s.trim().toLowerCase()); + const notSigned = new RegExp(process.env.NOT_SIGNED_REGEX, 'i'); + const signed = new RegExp(process.env.SIGNED_REGEX, 'i'); + + // Resolve PR number for either trigger + const prNumber = context.eventName === 'pull_request_target' + ? context.payload.pull_request.number + : context.payload.issue.number; + + const { owner, repo } = context.repo; + + // Pull the full comment history to find the latest CLA bot comment + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: prNumber, per_page: 100, + }); + + const claComments = comments.filter(c => + botLogins.includes((c.user?.login || '').toLowerCase()) && + (notSigned.test(c.body) || signed.test(c.body)) + ); + + if (claComments.length === 0) { + core.info('No CLA Assistant comment found yet; nothing to do.'); + return; + } + + const latest = claComments[claComments.length - 1]; + const isSigned = signed.test(latest.body) && !notSigned.test(latest.body); + + core.info(`Latest CLA comment (id ${latest.id}) => signed=${isSigned}`); + + // Current labels + const { data: issue } = await github.rest.issues.get({ + owner, repo, issue_number: prNumber, + }); + const hasLabel = issue.labels.some(l => + (typeof l === 'string' ? l : l.name) === AWAITING_LABEL + ); + + if (isSigned && hasLabel) { + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: AWAITING_LABEL, + }).catch(e => core.warning(`removeLabel failed: ${e.message}`)); + core.info(`Removed "${AWAITING_LABEL}".`); + } else if (!isSigned && !hasLabel) { + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: [AWAITING_LABEL], + }); + core.info(`Added "${AWAITING_LABEL}".`); + } else { + core.info('Label already in the correct state.'); + } From a16d317c1da78ba1a9cba9586a2a082333457fa1 Mon Sep 17 00:00:00 2001 From: GCHQDeveloper581 <63102987+GCHQDeveloper581@users.noreply.github.com> Date: Wed, 1 Jul 2026 13:30:04 +0000 Subject: [PATCH 2/2] Use actions/stale rather than a custom script to handle commenting on/closing non CLAed PRs --- .github/workflows/cla-close-stale.yml | 181 +++++++------------------- 1 file changed, 49 insertions(+), 132 deletions(-) diff --git a/.github/workflows/cla-close-stale.yml b/.github/workflows/cla-close-stale.yml index 10270c7da1..d76ecd8c23 100644 --- a/.github/workflows/cla-close-stale.yml +++ b/.github/workflows/cla-close-stale.yml @@ -1,144 +1,61 @@ -name: CLA Stale PR Closer +name: Close Stale Unsigned CLA PRs on: schedule: - # Runs daily at 02:00 UTC. Adjust as required. - - cron: '0 2 * * *' - workflow_dispatch: - inputs: - dry_run: - description: 'Log actions without applying them' - type: boolean - default: false + # Runs daily at 01:30 UTC. + - cron: '30 1 * * *' + workflow_dispatch: {} permissions: + contents: read pull-requests: write issues: write - contents: read + +# Configurable intervals (days). +# INTERVAL_1 = grace period before the warning comment. +# INTERVAL_2 = further period after the warning before closing. +env: + INTERVAL_1: 14 + INTERVAL_2: 14 jobs: - close-stale: + stale: runs-on: ubuntu-latest - env: - AWAITING_LABEL: 'awaiting cla' - - # --- Configurable intervals (in days) --- - # interval 1: days of inactivity before the warning comment is posted - WARN_AFTER_DAYS: '14' - # interval 2: days after the warning before the PR is closed - CLOSE_AFTER_DAYS: '14' - - # --- Detection config (keep in sync with cla-label.yml) --- - CLA_BOT_LOGINS: 'CLAassistant,cla-assistant[bot],github-actions[bot]' - NOT_SIGNED_REGEX: 'have not signed|has not signed|please sign|we need.*sign|\[ \]' - SIGNED_REGEX: 'all committers have signed|all contributors have signed|has signed the cla|have signed the cla' - - # A unique marker so we only post the warning once and can find it later. - WARN_MARKER: '' - CLOSE_MARKER: '' - steps: - - name: Process open PRs awaiting CLA - uses: actions/github-script@v7 + - name: Close stale unsigned-CLA PRs + uses: actions/stale@v9 with: - script: | - const { owner, repo } = context.repo; - const AWAITING_LABEL = process.env.AWAITING_LABEL; - const WARN_AFTER_DAYS = parseInt(process.env.WARN_AFTER_DAYS, 10); - const CLOSE_AFTER_DAYS = parseInt(process.env.CLOSE_AFTER_DAYS, 10); - const WARN_MARKER = process.env.WARN_MARKER; - const CLOSE_MARKER = process.env.CLOSE_MARKER; - const dryRun = context.payload.inputs?.dry_run === 'true' - || context.payload.inputs?.dry_run === true; - - const botLogins = process.env.CLA_BOT_LOGINS.split(',').map(s => s.trim().toLowerCase()); - const notSigned = new RegExp(process.env.NOT_SIGNED_REGEX, 'i'); - const signed = new RegExp(process.env.SIGNED_REGEX, 'i'); - - const DAY_MS = 24 * 60 * 60 * 1000; - const now = Date.now(); - - const daysBetween = (fromIso) => (now - new Date(fromIso).getTime()) / DAY_MS; - - // Fetch all open PRs. (We check the label per-PR to keep it robust - // even if the label sync workflow lagged.) - const prs = await github.paginate(github.rest.pulls.list, { - owner, repo, state: 'open', per_page: 100, - }); - - core.info(`Scanning ${prs.length} open PR(s). dryRun=${dryRun}`); - - for (const pr of prs) { - const num = pr.number; - - const hasLabel = pr.labels.some(l => - (typeof l === 'string' ? l : l.name) === AWAITING_LABEL - ); - if (!hasLabel) continue; - - // Load comments to (a) re-confirm CLA state and (b) find our marker. - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number: num, per_page: 100, - }); - - // Re-confirm the CLA is genuinely still unsigned, to avoid closing - // a PR where the label sync simply hasn't caught up. - const claComments = comments.filter(c => - botLogins.includes((c.user?.login || '').toLowerCase()) && - (notSigned.test(c.body) || signed.test(c.body)) - ); - if (claComments.length > 0) { - const latest = claComments[claComments.length - 1]; - const isSigned = signed.test(latest.body) && !notSigned.test(latest.body); - if (isSigned) { - core.info(`PR #${num}: CLA appears signed; skipping (label sync will remove it).`); - continue; - } - } - - // Find our previously posted warning, if any. - const warnComment = comments.find(c => (c.body || '').includes(WARN_MARKER)); - - if (!warnComment) { - // No warning yet — check whether we've passed interval 1. - // "Age" is measured from the PR creation date. Swap for - // pr.updated_at if you prefer inactivity-based timing. - const ageDays = daysBetween(pr.created_at); - if (ageDays >= WARN_AFTER_DAYS) { - const body = - `${WARN_MARKER}\n` + - `⚠️ This PR will be automatically closed if the CLA is not ` + - `signed within ${CLOSE_AFTER_DAYS} days.`; - core.info(`PR #${num}: posting warning (age ${ageDays.toFixed(1)}d).`); - if (!dryRun) { - await github.rest.issues.createComment({ - owner, repo, issue_number: num, body, - }); - } - } else { - core.info(`PR #${num}: below warn threshold (age ${ageDays.toFixed(1)}d).`); - } - continue; - } - - // Warning already posted — check whether interval 2 has elapsed. - const sinceWarnDays = daysBetween(warnComment.created_at); - if (sinceWarnDays >= CLOSE_AFTER_DAYS) { - core.info(`PR #${num}: closing (warned ${sinceWarnDays.toFixed(1)}d ago).`); - if (!dryRun) { - await github.rest.issues.createComment({ - owner, repo, issue_number: num, - body: - `${CLOSE_MARKER}\n` + - `This PR has been automatically closed as the CLA has not ` + - `been signed. We are happy to have it reopened if the CLA ` + - `is signed subsequently.`, - }); - await github.rest.pulls.update({ - owner, repo, pull_number: num, state: 'closed', - }); - } - } else { - core.info(`PR #${num}: warned but grace period not elapsed (${sinceWarnDays.toFixed(1)}/${CLOSE_AFTER_DAYS}d).`); - } - } + # ---- Guards: only act on PRs carrying the CLA label ---- + only-labels: 'awaiting cla' + + # Never touch issues — PRs only. + days-before-issue-stale: -1 + days-before-issue-close: -1 + + # ---- Timing ---- + # INTERVAL_1: days of inactivity before the warning comment. + days-before-pr-stale: ${{ env.INTERVAL_1 }} + # INTERVAL_2: days after being marked stale before closing. + days-before-pr-close: ${{ env.INTERVAL_2 }} + + # ---- Warning comment (posted once when marked stale) ---- + stale-pr-message: > + This PR will be automatically closed if the CLA is not signed + within ${{ env.INTERVAL_2 }} days. + + # ---- Close comment ---- + close-pr-message: > + This PR has been automatically closed as the CLA has not been + signed. We are happy to have it reopened if the CLA is signed + subsequently. + + # A dedicated marker label so we can track stale state without + # interfering with the "awaiting cla" label. + stale-pr-label: 'cla-stale' + + # If the PR is updated after being marked stale, remove the marker + # so the warning-then-close cycle restarts cleanly. + remove-pr-stale-when-updated: true + + # Process enough PRs per run for busy repos. + operations-per-run: 200