diff --git a/.github/workflows/cla-close-stale.yml b/.github/workflows/cla-close-stale.yml new file mode 100644 index 0000000000..d76ecd8c23 --- /dev/null +++ b/.github/workflows/cla-close-stale.yml @@ -0,0 +1,61 @@ +name: Close Stale Unsigned CLA PRs + +on: + schedule: + # Runs daily at 01:30 UTC. + - cron: '30 1 * * *' + workflow_dispatch: {} + +permissions: + contents: read + pull-requests: write + issues: write + +# 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: + stale: + runs-on: ubuntu-latest + steps: + - name: Close stale unsigned-CLA PRs + uses: actions/stale@v9 + with: + # ---- 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 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.'); + }