diff --git a/.github/workflows/cla-label-sync.yml b/.github/workflows/cla-label-sync.yml new file mode 100644 index 0000000000..69f9571ce6 --- /dev/null +++ b/.github/workflows/cla-label-sync.yml @@ -0,0 +1,345 @@ +name: CLA Label Sync + +on: + # Real-time: when CLA check completes + check_run: + types: [completed] + + # When PRs are opened or updated + pull_request_target: + types: [opened, synchronize, reopened] + + # Scheduled sweep - check stale PRs daily + schedule: + - cron: '0 9 * * *' # 9 AM UTC daily + + # Manual trigger for testing + workflow_dispatch: + inputs: + pr_number: + description: 'Specific PR number to check (optional)' + required: false + +permissions: + pull-requests: write + contents: read + +env: + CLA_CHECK_NAME: 'license/cla' + LABEL_PENDING: 'cla: pending' + LABEL_SIGNED: 'cla: signed' + # Timing configuration + REMINDER_DAYS: 7 # Days before first reminder + CLOSE_DAYS: 30 # Days before auto-close + +jobs: + sync-labels: + runs-on: ubuntu-latest + + steps: + - name: Ensure CLA labels exist + uses: actions/github-script@v7 + with: + script: | + const labels = [ + { name: 'cla: pending', color: 'fbca04', description: 'CLA not yet signed by all contributors' }, + { name: 'cla: signed', color: '0e8a16', description: 'CLA signed by all contributors' } + ]; + + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name + }); + } catch (e) { + if (e.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description + }); + console.log(`Created label: ${label.name}`); + } + } + } + + - name: Sync CLA labels and handle stale PRs + uses: actions/github-script@v7 + with: + script: | + const CLA_CHECK_NAME = process.env.CLA_CHECK_NAME; + const LABEL_PENDING = process.env.LABEL_PENDING; + const LABEL_SIGNED = process.env.LABEL_SIGNED; + const REMINDER_DAYS = parseInt(process.env.REMINDER_DAYS); + const CLOSE_DAYS = parseInt(process.env.CLOSE_DAYS); + + const CLA_SIGN_URL = `https://cla-assistant.io/${context.repo.owner}/${context.repo.repo}`; + + // Helper: Get CLA status for a PR + async function getClaStatus(prNumber, headSha) { + // CLA-assistant uses the commit status API (not checks API) + const { data: statuses } = await github.rest.repos.getCombinedStatusForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: headSha + }); + + const claStatus = statuses.statuses.find( + s => s.context === CLA_CHECK_NAME + ); + + if (claStatus) { + return { + found: true, + passed: claStatus.state === 'success', + state: claStatus.state, + description: claStatus.description + }; + } + + // Fallback: check the Checks API too + const { data: checkRuns } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: headSha + }); + + const claCheck = checkRuns.check_runs.find( + check => check.name === CLA_CHECK_NAME + ); + + if (claCheck) { + return { + found: true, + passed: claCheck.conclusion === 'success', + state: claCheck.conclusion, + description: claCheck.output?.summary || '' + }; + } + + return { found: false, passed: false, state: 'unknown' }; + } + + // Helper: Check if bot already commented with a specific marker + async function hasCommentWithMarker(prNumber, marker) { + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100 + }); + + return comments.some(c => + c.user.type === 'Bot' && + c.body.includes(marker) + ); + } + + // Helper: Days since a date + function daysSince(dateString) { + const date = new Date(dateString); + const now = new Date(); + return Math.floor((now - date) / (1000 * 60 * 60 * 24)); + } + + // Determine which PRs to check + let prsToCheck = []; + + if (context.eventName === 'check_run') { + // Only process if it's the CLA check + if (context.payload.check_run.name !== CLA_CHECK_NAME) { + console.log(`Ignoring check: ${context.payload.check_run.name}`); + return; + } + const prs = context.payload.check_run.pull_requests || []; + prsToCheck = prs.map(pr => pr.number); + + } else if (context.eventName === 'pull_request_target') { + prsToCheck = [context.payload.pull_request.number]; + + } else if (context.eventName === 'workflow_dispatch' && context.payload.inputs?.pr_number) { + prsToCheck = [parseInt(context.payload.inputs.pr_number)]; + + } else { + // Scheduled run: check all open PRs + const { data: openPRs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + prsToCheck = openPRs.map(pr => pr.number); + } + + console.log(`Checking ${prsToCheck.length} PR(s): ${prsToCheck.join(', ')}`); + + for (const prNumber of prsToCheck) { + try { + // Get PR details + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + // Skip if PR is from a bot + if (pr.user.type === 'Bot') { + console.log(`PR #${prNumber}: Skipping bot PR`); + continue; + } + + const claStatus = await getClaStatus(prNumber, pr.head.sha); + const currentLabels = pr.labels.map(l => l.name); + const hasPending = currentLabels.includes(LABEL_PENDING); + const hasSigned = currentLabels.includes(LABEL_SIGNED); + const prAgeDays = daysSince(pr.created_at); + + console.log(`PR #${prNumber}: CLA ${claStatus.passed ? 'passed' : 'pending'} (${claStatus.state}), age: ${prAgeDays} days`); + + if (claStatus.passed) { + // ✅ CLA signed - add signed label, remove pending + if (!hasSigned) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [LABEL_SIGNED] + }); + console.log(`Added '${LABEL_SIGNED}' to PR #${prNumber}`); + } + if (hasPending) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: LABEL_PENDING + }); + console.log(`Removed '${LABEL_PENDING}' from PR #${prNumber}`); + } + + } else { + // ⏳ CLA pending + + // Add pending label if not present + if (!hasPending) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [LABEL_PENDING] + }); + console.log(`Added '${LABEL_PENDING}' to PR #${prNumber}`); + } + if (hasSigned) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: LABEL_SIGNED + }); + console.log(`Removed '${LABEL_SIGNED}' from PR #${prNumber}`); + } + + // Check if we need to send reminder or close + const REMINDER_MARKER = ''; + const CLOSE_WARNING_MARKER = ''; + + // 📢 Reminder after REMINDER_DAYS + if (prAgeDays >= REMINDER_DAYS && prAgeDays < CLOSE_DAYS) { + const hasReminder = await hasCommentWithMarker(prNumber, REMINDER_MARKER); + + if (!hasReminder) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `${REMINDER_MARKER} + + 👋 **Friendly reminder:** This PR is waiting on a signed CLA. + + All contributors need to sign our Contributor License Agreement before we can merge this PR. + + **➡️ [Sign the CLA here](${CLA_SIGN_URL}?pullRequest=${prNumber})** + +
+ Why do we need a CLA? + + The CLA protects both you and the project by clarifying the terms under which your contribution is made. It's a one-time process — once signed, it covers all your future contributions. + +
+ +
+ Common issues + + - **Email mismatch:** Make sure your Git commit email matches your GitHub account email + - **Merge commits:** If you merged \`dev\` into your branch, try rebasing instead: \`git rebase origin/dev && git push --force-with-lease\` + - **Multiple authors:** All commit authors need to sign, not just the PR author + +
+ + If you have questions, just ask! 🙂` + }); + console.log(`Posted reminder on PR #${prNumber}`); + } + } + + // ⚠️ Close warning at CLOSE_DAYS - 7 + if (prAgeDays >= CLOSE_DAYS - 7 && prAgeDays < CLOSE_DAYS) { + const hasCloseWarning = await hasCommentWithMarker(prNumber, CLOSE_WARNING_MARKER); + + if (!hasCloseWarning) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `${CLOSE_WARNING_MARKER} + + ⚠️ **This PR will be automatically closed in 7 days** if the CLA is not signed. + + We haven't received a signed CLA from all contributors yet. Please sign it to keep this PR open: + + **➡️ [Sign the CLA here](${CLA_SIGN_URL}?pullRequest=${prNumber})** + + If you're unable to sign or have questions, please let us know — we're happy to help!` + }); + console.log(`Posted close warning on PR #${prNumber}`); + } + } + + // 🚪 Auto-close after CLOSE_DAYS + if (prAgeDays >= CLOSE_DAYS) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `👋 Closing this PR due to unsigned CLA after ${CLOSE_DAYS} days. + + Thank you for your contribution! If you'd still like to contribute: + + 1. [Sign the CLA](${CLA_SIGN_URL}) + 2. Re-open this PR or create a new one + + We appreciate your interest in AutoGPT and hope to see you back! 🚀` + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); + + console.log(`Closed PR #${prNumber} due to unsigned CLA`); + } + } + + } catch (error) { + console.error(`Error processing PR #${prNumber}: ${error.message}`); + } + } + + console.log('CLA label sync complete!');