|
| 1 | +/** |
| 2 | + * @license |
| 3 | + * Copyright 2026 Google LLC |
| 4 | + * SPDX-License-Identifier: Apache-2.0 |
| 5 | + */ |
| 6 | + |
| 7 | +/* eslint-disable */ |
| 8 | +/* global require, console, process */ |
| 9 | + |
| 10 | +/** |
| 11 | + * Script to backfill a process change notification comment to all open PRs |
| 12 | + * not created by members of the 'gemini-cli-maintainers' team. |
| 13 | + * |
| 14 | + * Skip PRs that are already associated with an issue. |
| 15 | + */ |
| 16 | + |
| 17 | +const { execFileSync } = require('child_process'); |
| 18 | + |
| 19 | +const isDryRun = process.argv.includes('--dry-run'); |
| 20 | +const REPO = 'google-gemini/gemini-cli'; |
| 21 | +const ORG = 'google-gemini'; |
| 22 | +const TEAM_SLUG = 'gemini-cli-maintainers'; |
| 23 | +const DISCUSSION_URL = |
| 24 | + 'https://github.com/google-gemini/gemini-cli/discussions/16706'; |
| 25 | + |
| 26 | +/** |
| 27 | + * Executes a GitHub CLI command safely using an argument array. |
| 28 | + */ |
| 29 | +function runGh(args, options = {}) { |
| 30 | + const { silent = false } = options; |
| 31 | + try { |
| 32 | + return execFileSync('gh', args, { |
| 33 | + encoding: 'utf8', |
| 34 | + maxBuffer: 10 * 1024 * 1024, |
| 35 | + stdio: ['ignore', 'pipe', 'pipe'], |
| 36 | + }).trim(); |
| 37 | + } catch (error) { |
| 38 | + if (!silent) { |
| 39 | + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; |
| 40 | + console.error( |
| 41 | + `❌ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, |
| 42 | + ); |
| 43 | + } |
| 44 | + return null; |
| 45 | + } |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * Checks if a user is a member of the maintainers team. |
| 50 | + */ |
| 51 | +const membershipCache = new Map(); |
| 52 | +function isMaintainer(username) { |
| 53 | + if (membershipCache.has(username)) return membershipCache.get(username); |
| 54 | + |
| 55 | + // GitHub returns 404 if user is not a member. |
| 56 | + // We use silent: true to avoid logging 404s as errors. |
| 57 | + const result = runGh( |
| 58 | + ['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`], |
| 59 | + { silent: true }, |
| 60 | + ); |
| 61 | + |
| 62 | + const isMember = result !== null; |
| 63 | + membershipCache.set(username, isMember); |
| 64 | + return isMember; |
| 65 | +} |
| 66 | + |
| 67 | +async function main() { |
| 68 | + console.log('🔐 GitHub CLI security check...'); |
| 69 | + if (runGh(['auth', 'status']) === null) { |
| 70 | + console.error('❌ GitHub CLI (gh) is not authenticated.'); |
| 71 | + process.exit(1); |
| 72 | + } |
| 73 | + |
| 74 | + if (isDryRun) { |
| 75 | + console.log('🧪 DRY RUN MODE ENABLED\n'); |
| 76 | + } |
| 77 | + |
| 78 | + console.log(`📥 Fetching open PRs from ${REPO}...`); |
| 79 | + // Fetch number, author, and closingIssuesReferences to check if linked to an issue |
| 80 | + const prsJson = runGh([ |
| 81 | + 'pr', |
| 82 | + 'list', |
| 83 | + '--repo', |
| 84 | + REPO, |
| 85 | + '--state', |
| 86 | + 'open', |
| 87 | + '--limit', |
| 88 | + '1000', |
| 89 | + '--json', |
| 90 | + 'number,author,closingIssuesReferences', |
| 91 | + ]); |
| 92 | + |
| 93 | + if (prsJson === null) process.exit(1); |
| 94 | + const prs = JSON.parse(prsJson); |
| 95 | + |
| 96 | + console.log(`📊 Found ${prs.length} open PRs. Filtering...`); |
| 97 | + |
| 98 | + let targetPrs = []; |
| 99 | + for (const pr of prs) { |
| 100 | + const author = pr.author.login; |
| 101 | + const issueCount = pr.closingIssuesReferences |
| 102 | + ? pr.closingIssuesReferences.length |
| 103 | + : 0; |
| 104 | + |
| 105 | + if (issueCount > 0) { |
| 106 | + // Skip if already linked to an issue |
| 107 | + continue; |
| 108 | + } |
| 109 | + |
| 110 | + if (!isMaintainer(author)) { |
| 111 | + targetPrs.push(pr); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + console.log( |
| 116 | + `✅ Found ${targetPrs.length} PRs from non-maintainers without associated issues.`, |
| 117 | + ); |
| 118 | + |
| 119 | + const commentBody = |
| 120 | + "\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(); |
| 121 | + |
| 122 | + let successCount = 0; |
| 123 | + let skipCount = 0; |
| 124 | + let failCount = 0; |
| 125 | + |
| 126 | + for (const pr of targetPrs) { |
| 127 | + const prNumber = String(pr.number); |
| 128 | + const author = pr.author.login; |
| 129 | + |
| 130 | + // Check if we already commented (idempotency) |
| 131 | + // We use silent: true here because view might fail if PR is deleted mid-run |
| 132 | + const existingComments = runGh( |
| 133 | + [ |
| 134 | + 'pr', |
| 135 | + 'view', |
| 136 | + prNumber, |
| 137 | + '--repo', |
| 138 | + REPO, |
| 139 | + '--json', |
| 140 | + 'comments', |
| 141 | + '--jq', |
| 142 | + `.comments[].body | contains("${DISCUSSION_URL}")`, |
| 143 | + ], |
| 144 | + { silent: true }, |
| 145 | + ); |
| 146 | + |
| 147 | + if (existingComments && existingComments.includes('true')) { |
| 148 | + console.log( |
| 149 | + `⏭️ PR #${prNumber} already has the notification. Skipping.`, |
| 150 | + ); |
| 151 | + skipCount++; |
| 152 | + continue; |
| 153 | + } |
| 154 | + |
| 155 | + if (isDryRun) { |
| 156 | + console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`); |
| 157 | + successCount++; |
| 158 | + } else { |
| 159 | + console.log(`💬 Notifying @${author} on PR #${prNumber}...`); |
| 160 | + const personalizedComment = commentBody.replace('{AUTHOR}', author); |
| 161 | + const result = runGh([ |
| 162 | + 'pr', |
| 163 | + 'comment', |
| 164 | + prNumber, |
| 165 | + '--repo', |
| 166 | + REPO, |
| 167 | + '--body', |
| 168 | + personalizedComment, |
| 169 | + ]); |
| 170 | + |
| 171 | + if (result !== null) { |
| 172 | + successCount++; |
| 173 | + } else { |
| 174 | + failCount++; |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + console.log(`\n📊 Summary:`); |
| 180 | + console.log(` - Notified: ${successCount}`); |
| 181 | + console.log(` - Skipped: ${skipCount}`); |
| 182 | + console.log(` - Failed: ${failCount}`); |
| 183 | + |
| 184 | + if (failCount > 0) process.exit(1); |
| 185 | +} |
| 186 | + |
| 187 | +main().catch((e) => { |
| 188 | + console.error(e); |
| 189 | + process.exit(1); |
| 190 | +}); |
0 commit comments