diff --git a/.github/scripts/backfill-pr-notification.cjs b/.github/scripts/backfill-pr-notification.cjs new file mode 100644 index 0000000000..3014398519 --- /dev/null +++ b/.github/scripts/backfill-pr-notification.cjs @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable */ +/* global require, console, process */ + +/** + * Script to backfill a process change notification comment to all open PRs + * not created by members of the 'gemini-cli-maintainers' team. + * + * Skip PRs that are already associated with an issue. + */ + +const { execFileSync } = require('child_process'); + +const isDryRun = process.argv.includes('--dry-run'); +const REPO = 'google-gemini/gemini-cli'; +const ORG = 'google-gemini'; +const TEAM_SLUG = 'gemini-cli-maintainers'; +const DISCUSSION_URL = + 'https://github.com/google-gemini/gemini-cli/discussions/16706'; + +/** + * Executes a GitHub CLI command safely using an argument array. + */ +function runGh(args, options = {}) { + const { silent = false } = options; + try { + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + if (!silent) { + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; + console.error( + `❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, + ); + } + return null; + } +} + +/** + * Checks if a user is a member of the maintainers team. + */ +const membershipCache = new Map(); +function isMaintainer(username) { + if (membershipCache.has(username)) return membershipCache.get(username); + + // GitHub returns 404 if user is not a member. + // We use silent: true to avoid logging 404s as errors. + const result = runGh( + ['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`], + { silent: true }, + ); + + const isMember = result !== null; + membershipCache.set(username, isMember); + return isMember; +} + +async function main() { + console.log('🔐 GitHub CLI security check...'); + if (runGh(['auth', 'status']) === null) { + console.error('❌ GitHub CLI (gh) is not authenticated.'); + process.exit(1); + } + + if (isDryRun) { + console.log('🧪 DRY RUN MODE ENABLED\n'); + } + + console.log(`📥 Fetching open PRs from ${REPO}...`); + // Fetch number, author, and closingIssuesReferences to check if linked to an issue + const prsJson = runGh([ + 'pr', + 'list', + '--repo', + REPO, + '--state', + 'open', + '--limit', + '1000', + '--json', + 'number,author,closingIssuesReferences', + ]); + + if (prsJson === null) process.exit(1); + const prs = JSON.parse(prsJson); + + console.log(`📊 Found ${prs.length} open PRs. Filtering...`); + + let targetPrs = []; + for (const pr of prs) { + const author = pr.author.login; + const issueCount = pr.closingIssuesReferences + ? pr.closingIssuesReferences.length + : 0; + + if (issueCount > 0) { + // Skip if already linked to an issue + continue; + } + + if (!isMaintainer(author)) { + targetPrs.push(pr); + } + } + + console.log( + `✅ Found ${targetPrs.length} PRs from non-maintainers without associated issues.`, + ); + + const commentBody = + "\nHi @{AUTHOR}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.\n\nWe're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](${DISCUSSION_URL}).\n\nKey Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.\n\nThank you for your understanding and for being a part of our community!\n ".trim(); + + let successCount = 0; + let skipCount = 0; + let failCount = 0; + + for (const pr of targetPrs) { + const prNumber = String(pr.number); + const author = pr.author.login; + + // Check if we already commented (idempotency) + // We use silent: true here because view might fail if PR is deleted mid-run + const existingComments = runGh( + [ + 'pr', + 'view', + prNumber, + '--repo', + REPO, + '--json', + 'comments', + '--jq', + `.comments[].body | contains("${DISCUSSION_URL}")`, + ], + { silent: true }, + ); + + if (existingComments && existingComments.includes('true')) { + console.log( + `⏭️ PR #${prNumber} already has the notification. Skipping.`, + ); + skipCount++; + continue; + } + + if (isDryRun) { + console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`); + successCount++; + } else { + console.log(`💬 Notifying @${author} on PR #${prNumber}...`); + const personalizedComment = commentBody.replace('{AUTHOR}', author); + const result = runGh([ + 'pr', + 'comment', + prNumber, + '--repo', + REPO, + '--body', + personalizedComment, + ]); + + if (result !== null) { + successCount++; + } else { + failCount++; + } + } + } + + console.log(`\n📊 Summary:`); + console.log(` - Notified: ${successCount}`); + console.log(` - Skipped: ${skipCount}`); + console.log(` - Failed: ${failCount}`); + + if (failCount > 0) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.github/workflows/label-enforcer.yml b/.github/workflows/label-enforcer.yml new file mode 100644 index 0000000000..173a80c103 --- /dev/null +++ b/.github/workflows/label-enforcer.yml @@ -0,0 +1,113 @@ +name: '🏷️ Enforce Restricted Label Permissions' + +on: + issues: + types: + - 'labeled' + - 'unlabeled' + +jobs: + enforce-label: + # Run this job only when restricted labels are changed + if: |- + ${{ (github.event.label.name == 'help wanted' || github.event.label.name == 'status/need-triage') && + (github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') }} + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Check if user is in the maintainers team' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const org = context.repo.owner; + const username = context.payload.sender.login; + const team_slug = 'gemini-cli-maintainers'; + const action = context.payload.action; // 'labeled' or 'unlabeled' + const labelName = context.payload.label.name; + + // Skip if the change was made by a bot to avoid infinite loops + if (username === 'github-actions[bot]') { + core.info('Change made by a bot. Skipping.'); + return; + } + + try { + // This will succeed with a 204 status if the user is a member, + // and fail with a 404 error if they are not. + await github.rest.teams.getMembershipForUserInOrg ({ + org, + team_slug, + username, + }); + core.info(`${username} is a member of the ${team_slug} team. No action needed.`); + } catch (error) { + // If the error is not 404, rethrow it to fail the action + if (error.status !== 404) { + throw error; + } + + core.info(`${username} is not a member. Reverting '${action}' action for '${labelName}' label.`); + + if (action === 'labeled') { + // 1. Remove the label if added by a non-maintainer + await github.rest.issues.removeLabel ({ + owner: org, + repo: context.repo.repo, + issue_number: context.issue.number, + name: labelName + }); + + // 2. Post a polite comment + const comment = ` + Hi @${username}, thank you for your interest in helping triage issues! + + The \`${labelName}\` label is reserved for project maintainers to apply. This helps us ensure that an issue is ready and properly vetted for community contribution. + + A maintainer will review this issue soon. Please see our [CONTRIBUTING.md](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md) for more details on our labeling process. + `.trim().replace(/^[ ]+/gm, ''); + + await github.rest.issues.createComment ({ + owner: org, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } else if (action === 'unlabeled') { + // 1. Add the label back if removed by a non-maintainer + await github.rest.issues.addLabels ({ + owner: org, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [labelName] + }); + + // 2. Post a polite comment + const comment = ` + Hi @${username}, it looks like the \`${labelName}\` label was removed. + + This label is managed by project maintainers. We've added it back to ensure the issue remains visible to potential contributors until a maintainer decides otherwise. + + Thank you for your understanding! + `.trim().replace(/^[ ]+/gm, ''); + + await github.rest.issues.createComment ({ + owner: org, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + } diff --git a/.github/workflows/pr-contribution-guidelines-notifier.yml b/.github/workflows/pr-contribution-guidelines-notifier.yml new file mode 100644 index 0000000000..c9bebbb3c5 --- /dev/null +++ b/.github/workflows/pr-contribution-guidelines-notifier.yml @@ -0,0 +1,90 @@ +name: '🏷️ PR Contribution Guidelines Notifier' + +on: + pull_request: + types: + - 'opened' + +jobs: + notify-process-change: + runs-on: 'ubuntu-latest' + if: |- + github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' + permissions: + pull-requests: 'write' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Check membership and post comment' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const org = context.repo.owner; + const repo = context.repo.repo; + const username = context.payload.pull_request.user.login; + const pr_number = context.payload.pull_request.number; + const team_slug = 'gemini-cli-maintainers'; + + // 1. Check if the PR author is a maintainer + try { + await github.rest.teams.getMembershipForUserInOrg({ + org, + team_slug, + username, + }); + core.info(`${username} is a maintainer. No notification needed.`); + return; + } catch (error) { + if (error.status !== 404) throw error; + } + + // 2. Check if the PR is already associated with an issue + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + closingIssuesReferences(first: 1) { + totalCount + } + } + } + } + `; + const variables = { owner: org, repo: repo, number: pr_number }; + const result = await github.graphql(query, variables); + const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount; + + if (issueCount > 0) { + core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`); + return; + } + + // 3. Post the notification comment + core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`); + + const comment = ` + Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this. + + We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706). + + Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed. + + Thank you for your understanding and for being a part of our community! + `.trim().replace(/^[ ]+/gm, ''); + + await github.rest.issues.createComment({ + owner: org, + repo: repo, + issue_number: pr_number, + body: comment + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d96c25b5b7..d1848f143c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,13 @@ This project follows The process for contributing code is as follows: 1. **Find an issue** that you want to work on. If an issue is tagged as - "🔒Maintainers only", this means it is reserved for project maintainers. We - will not accept pull requests related to these issues. + `🔒Maintainers only`, this means it is reserved for project maintainers. We + will not accept pull requests related to these issues. In the near future, + we will explicitly mark issues looking for contributions using the + `help wanted` label. If you believe an issue is a good candidate for + community contribution, please leave a comment on the issue. A maintainer + will review it and apply the `help-wanted` label if appropriate. Only + maintainers should attempt to add the `help-wanted` label to an issue. 2. **Fork the repository** and create a new branch. 3. **Make your changes** in the `packages/` directory. 4. **Ensure all checks pass** by running `npm run preflight`. @@ -94,8 +99,11 @@ any code is written. - **For features:** The PR should be linked to the feature request or proposal issue that has been approved by a maintainer. -If an issue for your change doesn't exist, please **open one first** and wait -for feedback before you start coding. +If an issue for your change doesn't exist, we will automatically close your PR +along with a comment reminding you to associate the PR with an issue. The ideal +workflow starts with an issue that has been reviewed and approved by a +maintainer. Please **open the issue first** and wait for feedback before you +start coding. #### 2. Keep it small and focused