Created
July 9, 2026 13:22
-
-
Save louwers/a7aa20e4fa474d907c552c4325c529ea to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| import { Octokit } from "@octokit/rest"; | |
| import { spawnSync } from "node:child_process"; | |
| import { createInterface } from "node:readline/promises"; | |
| const DEFAULT_OWNER = "maplibre"; | |
| const DEFAULT_REPO = "maplibre-native"; | |
| function printUsage() { | |
| console.log(`Usage: node scripts/inactive-contributors.mjs [options] | |
| Outputs collaborators with write permission or above who have not opened a PR | |
| and have not submitted a PR review during the selected time window. | |
| Authentication: | |
| Uses GH_TOKEN or GITHUB_TOKEN when set. Otherwise, uses 'gh auth token'. | |
| The token must be able to read repository collaborators. | |
| Applying permission changes requires repository admin permission. | |
| Options: | |
| --owner <owner> GitHub repository owner. Defaults to ${DEFAULT_OWNER}. | |
| --repo <repo> GitHub repository name. Defaults to ${DEFAULT_REPO}. | |
| --months <months> Look back this many months. Defaults to 6. | |
| --since <YYYY-MM-DD> Use an explicit UTC start date instead of --months. | |
| --include-bots Include bot accounts. | |
| --json Print machine-readable JSON without prompting. | |
| --no-interactive Only print the report; do not prompt for permission changes. | |
| --verbose Print progress to stderr. | |
| --help Show this help message. | |
| `); | |
| } | |
| function fail(message) { | |
| console.error(`Error: ${message}`); | |
| console.error("Run with --help for usage."); | |
| process.exit(1); | |
| } | |
| function getGitHubToken() { | |
| if (process.env.GH_TOKEN) { | |
| return { token: process.env.GH_TOKEN, source: "GH_TOKEN" }; | |
| } | |
| if (process.env.GITHUB_TOKEN) { | |
| return { token: process.env.GITHUB_TOKEN, source: "GITHUB_TOKEN" }; | |
| } | |
| const result = spawnSync("gh", ["auth", "token"], { | |
| encoding: "utf8", | |
| }); | |
| if (result.error?.code === "ENOENT") { | |
| fail("Set GH_TOKEN/GITHUB_TOKEN or install and authenticate the GitHub CLI"); | |
| } | |
| if (result.error) { | |
| fail(`Failed to read token from GitHub CLI: ${result.error.message}`); | |
| } | |
| if (result.status !== 0) { | |
| fail("Failed to read token from GitHub CLI. Run 'gh auth login' first."); | |
| } | |
| const token = result.stdout.trim(); | |
| if (!token) { | |
| fail("GitHub CLI returned an empty token. Run 'gh auth login' first."); | |
| } | |
| return { token, source: "gh auth token" }; | |
| } | |
| function parseArgs(argv) { | |
| const repositoryFromEnv = process.env.GITHUB_REPOSITORY?.split("/"); | |
| const options = { | |
| owner: repositoryFromEnv?.[0] || DEFAULT_OWNER, | |
| repo: repositoryFromEnv?.[1] || DEFAULT_REPO, | |
| months: 6, | |
| since: null, | |
| includeBots: false, | |
| interactive: true, | |
| json: false, | |
| verbose: false, | |
| }; | |
| for (let i = 0; i < argv.length; i++) { | |
| const arg = argv[i]; | |
| switch (arg) { | |
| case "--help": | |
| case "-h": | |
| printUsage(); | |
| process.exit(0); | |
| break; | |
| case "--owner": | |
| options.owner = readValue(argv, ++i, arg); | |
| break; | |
| case "--repo": | |
| options.repo = readValue(argv, ++i, arg); | |
| break; | |
| case "--months": | |
| options.months = parsePositiveInteger(readValue(argv, ++i, arg), arg); | |
| break; | |
| case "--since": | |
| options.since = parseSince(readValue(argv, ++i, arg)); | |
| break; | |
| case "--include-bots": | |
| options.includeBots = true; | |
| break; | |
| case "--json": | |
| options.json = true; | |
| break; | |
| case "--no-interactive": | |
| options.interactive = false; | |
| break; | |
| case "--verbose": | |
| options.verbose = true; | |
| break; | |
| default: | |
| fail(`Unknown option: ${arg}`); | |
| } | |
| } | |
| return options; | |
| } | |
| function readValue(argv, index, optionName) { | |
| const value = argv[index]; | |
| if (!value || value.startsWith("--")) { | |
| fail(`${optionName} requires a value`); | |
| } | |
| return value; | |
| } | |
| function parsePositiveInteger(value, optionName) { | |
| const parsed = Number.parseInt(value, 10); | |
| if (!Number.isInteger(parsed) || parsed <= 0) { | |
| fail(`${optionName} must be a positive integer`); | |
| } | |
| return parsed; | |
| } | |
| function parseSince(value) { | |
| if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { | |
| fail("--since must use YYYY-MM-DD format"); | |
| } | |
| const since = new Date(`${value}T00:00:00.000Z`); | |
| if (Number.isNaN(since.getTime()) || since.toISOString().slice(0, 10) !== value) { | |
| fail(`Invalid --since date: ${value}`); | |
| } | |
| return since; | |
| } | |
| function subtractMonths(date, months) { | |
| const result = new Date(date); | |
| const originalDate = result.getUTCDate(); | |
| result.setUTCDate(1); | |
| result.setUTCMonth(result.getUTCMonth() - months); | |
| const lastDayOfTargetMonth = new Date( | |
| Date.UTC(result.getUTCFullYear(), result.getUTCMonth() + 1, 0), | |
| ).getUTCDate(); | |
| result.setUTCDate(Math.min(originalDate, lastDayOfTargetMonth)); | |
| return result; | |
| } | |
| function hasWritePermission(collaborator) { | |
| const permissions = collaborator.permissions || {}; | |
| return Boolean( | |
| permissions.admin || permissions.maintain || permissions.push, | |
| ); | |
| } | |
| function permissionName(collaborator) { | |
| if (collaborator.role_name) { | |
| return collaborator.role_name; | |
| } | |
| const permissions = collaborator.permissions || {}; | |
| if (permissions.admin) return "admin"; | |
| if (permissions.maintain) return "maintain"; | |
| if (permissions.push) return "write"; | |
| if (permissions.triage) return "triage"; | |
| if (permissions.pull) return "read"; | |
| return "unknown"; | |
| } | |
| function createContributor(collaborator) { | |
| return { | |
| login: collaborator.login, | |
| type: collaborator.type, | |
| permission: permissionName(collaborator), | |
| url: collaborator.html_url, | |
| openedPrs: new Map(), | |
| reviewedPrs: new Map(), | |
| }; | |
| } | |
| function openedPullSummary(pull) { | |
| return { | |
| number: pull.number, | |
| title: pull.title, | |
| url: pull.html_url, | |
| createdAt: pull.created_at, | |
| }; | |
| } | |
| function reviewSummary(pull, review) { | |
| return { | |
| number: pull.number, | |
| title: pull.title, | |
| url: pull.html_url, | |
| submittedAt: review.submitted_at, | |
| state: review.state, | |
| }; | |
| } | |
| async function listWriteCollaborators(octokit, options) { | |
| const contributors = new Map(); | |
| for await (const response of octokit.paginate.iterator( | |
| octokit.rest.repos.listCollaborators, | |
| { | |
| owner: options.owner, | |
| repo: options.repo, | |
| affiliation: "all", | |
| per_page: 100, | |
| }, | |
| )) { | |
| for (const collaborator of response.data) { | |
| if (!options.includeBots && collaborator.type === "Bot") { | |
| continue; | |
| } | |
| if (!hasWritePermission(collaborator)) { | |
| continue; | |
| } | |
| contributors.set( | |
| collaborator.login.toLowerCase(), | |
| createContributor(collaborator), | |
| ); | |
| } | |
| } | |
| return contributors; | |
| } | |
| async function scanPullRequests(octokit, options, since, contributors) { | |
| const stats = { | |
| pullRequestsScanned: 0, | |
| reviewsScanned: 0, | |
| }; | |
| for await (const response of octokit.paginate.iterator( | |
| octokit.rest.pulls.list, | |
| { | |
| owner: options.owner, | |
| repo: options.repo, | |
| state: "all", | |
| sort: "updated", | |
| direction: "desc", | |
| per_page: 100, | |
| }, | |
| )) { | |
| for (const pull of response.data) { | |
| if (new Date(pull.updated_at) < since) { | |
| return stats; | |
| } | |
| stats.pullRequestsScanned++; | |
| const authorLogin = pull.user?.login?.toLowerCase(); | |
| const author = contributors.get(authorLogin); | |
| if (author && new Date(pull.created_at) >= since) { | |
| author.openedPrs.set(pull.number, openedPullSummary(pull)); | |
| } | |
| await scanPullRequestReviews(octokit, options, since, contributors, pull, stats); | |
| } | |
| } | |
| return stats; | |
| } | |
| async function scanPullRequestReviews( | |
| octokit, | |
| options, | |
| since, | |
| contributors, | |
| pull, | |
| stats, | |
| ) { | |
| for await (const response of octokit.paginate.iterator( | |
| octokit.rest.pulls.listReviews, | |
| { | |
| owner: options.owner, | |
| repo: options.repo, | |
| pull_number: pull.number, | |
| per_page: 100, | |
| }, | |
| )) { | |
| for (const review of response.data) { | |
| stats.reviewsScanned++; | |
| if (!review.submitted_at || new Date(review.submitted_at) < since) { | |
| continue; | |
| } | |
| const reviewerLogin = review.user?.login?.toLowerCase(); | |
| const reviewer = contributors.get(reviewerLogin); | |
| if (!reviewer) { | |
| continue; | |
| } | |
| reviewer.reviewedPrs.set(pull.number, reviewSummary(pull, review)); | |
| } | |
| } | |
| } | |
| function formatDate(date) { | |
| return date.toISOString().replace(/\.\d{3}Z$/, "Z"); | |
| } | |
| function toSerializableContributor(contributor) { | |
| return { | |
| login: contributor.login, | |
| type: contributor.type, | |
| permission: contributor.permission, | |
| url: contributor.url, | |
| openedPrCount: contributor.openedPrs.size, | |
| reviewedPrCount: contributor.reviewedPrs.size, | |
| openedPrs: [...contributor.openedPrs.values()], | |
| reviewedPrs: [...contributor.reviewedPrs.values()], | |
| }; | |
| } | |
| function printJson(options, since, stats, contributors, inactiveContributors) { | |
| console.log( | |
| JSON.stringify( | |
| { | |
| repository: `${options.owner}/${options.repo}`, | |
| since: formatDate(since), | |
| includeBots: options.includeBots, | |
| stats: { | |
| writeCollaboratorCount: contributors.size, | |
| inactiveContributorCount: inactiveContributors.length, | |
| ...stats, | |
| }, | |
| inactiveContributors: inactiveContributors.map(toSerializableContributor), | |
| }, | |
| null, | |
| 2, | |
| ), | |
| ); | |
| } | |
| function printText(options, since, stats, contributors, inactiveContributors) { | |
| console.log(`Repository: ${options.owner}/${options.repo}`); | |
| console.log(`Since: ${formatDate(since)}`); | |
| console.log(`Write+ collaborators checked: ${contributors.size}`); | |
| console.log(`PRs scanned: ${stats.pullRequestsScanned}`); | |
| console.log(`Reviews scanned: ${stats.reviewsScanned}`); | |
| console.log(""); | |
| if (inactiveContributors.length === 0) { | |
| console.log("All write+ collaborators opened a PR or submitted a PR review in this window."); | |
| return; | |
| } | |
| console.log("Write+ collaborators with no PRs opened and no PR reviews submitted:"); | |
| for (const contributor of inactiveContributors) { | |
| console.log(`- ${contributor.login} (${contributor.permission}) ${contributor.url}`); | |
| } | |
| } | |
| function errorMessage(error) { | |
| return error.response?.data?.message || error.message; | |
| } | |
| async function readPermissionAction(rl, contributor, index, total) { | |
| while (true) { | |
| const answer = ( | |
| await rl.question( | |
| `[${index}/${total}] ${contributor.login} (${contributor.permission}) ${contributor.url}\n` + | |
| "Delete permissions (d), drop to read (r), keep (Enter): ", | |
| ) | |
| ) | |
| .trim() | |
| .toLowerCase(); | |
| if (answer === "" || answer === "k" || answer === "keep") { | |
| return "keep"; | |
| } | |
| if (answer === "d" || answer === "delete") { | |
| return "delete"; | |
| } | |
| if (answer === "r" || answer === "read") { | |
| return "read"; | |
| } | |
| console.log("Please enter d, r, or press Enter to keep."); | |
| } | |
| } | |
| async function deleteContributorPermissions(octokit, options, contributor) { | |
| await octokit.rest.repos.removeCollaborator({ | |
| owner: options.owner, | |
| repo: options.repo, | |
| username: contributor.login, | |
| }); | |
| } | |
| async function dropContributorToRead(octokit, options, contributor) { | |
| await octokit.rest.repos.addCollaborator({ | |
| owner: options.owner, | |
| repo: options.repo, | |
| username: contributor.login, | |
| permission: "pull", | |
| }); | |
| } | |
| async function applyPermissionAction(octokit, options, contributor, action) { | |
| if (action === "delete") { | |
| await deleteContributorPermissions(octokit, options, contributor); | |
| return "deleted"; | |
| } | |
| if (action === "read") { | |
| await dropContributorToRead(octokit, options, contributor); | |
| return "dropped-to-read"; | |
| } | |
| return "kept"; | |
| } | |
| function printPermissionActionSummary(results) { | |
| if (results.length === 0) { | |
| return; | |
| } | |
| const counts = results.reduce( | |
| (summary, result) => { | |
| summary[result.status] += 1; | |
| return summary; | |
| }, | |
| { | |
| deleted: 0, | |
| "dropped-to-read": 0, | |
| kept: 0, | |
| failed: 0, | |
| }, | |
| ); | |
| console.log(""); | |
| console.log("Permission action summary:"); | |
| console.log(`- Deleted: ${counts.deleted}`); | |
| console.log(`- Dropped to read: ${counts["dropped-to-read"]}`); | |
| console.log(`- Kept: ${counts.kept}`); | |
| console.log(`- Failed: ${counts.failed}`); | |
| } | |
| async function promptForPermissionActions(octokit, options, inactiveContributors) { | |
| if (inactiveContributors.length === 0) { | |
| return []; | |
| } | |
| if (!process.stdin.isTTY || !process.stdout.isTTY) { | |
| console.error("Skipping permission prompts because stdin/stdout is not interactive."); | |
| return []; | |
| } | |
| console.log(""); | |
| console.log("Review inactive contributor permissions:"); | |
| console.log("d = delete permissions, r = drop to read permissions, Enter = keep in place."); | |
| console.log("Note: permissions inherited from orgs or teams may need to be changed in GitHub."); | |
| const rl = createInterface({ | |
| input: process.stdin, | |
| output: process.stdout, | |
| }); | |
| const results = []; | |
| try { | |
| for (const [index, contributor] of inactiveContributors.entries()) { | |
| const action = await readPermissionAction( | |
| rl, | |
| contributor, | |
| index + 1, | |
| inactiveContributors.length, | |
| ); | |
| try { | |
| const status = await applyPermissionAction(octokit, options, contributor, action); | |
| results.push({ login: contributor.login, status }); | |
| if (status === "deleted") { | |
| console.log(`Removed ${contributor.login}'s repository permission.`); | |
| } else if (status === "dropped-to-read") { | |
| console.log(`Dropped ${contributor.login} to read permission.`); | |
| } else { | |
| console.log(`Kept ${contributor.login} in place.`); | |
| } | |
| } catch (error) { | |
| const message = errorMessage(error); | |
| results.push({ | |
| login: contributor.login, | |
| status: "failed", | |
| action, | |
| error: message, | |
| }); | |
| console.error(`Failed to update ${contributor.login}: ${message}`); | |
| } | |
| console.log(""); | |
| } | |
| } finally { | |
| rl.close(); | |
| } | |
| return results; | |
| } | |
| async function main() { | |
| const options = parseArgs(process.argv.slice(2)); | |
| const { token, source: tokenSource } = getGitHubToken(); | |
| const since = options.since || subtractMonths(new Date(), options.months); | |
| const octokit = new Octokit({ auth: token }); | |
| if (options.verbose) { | |
| console.error(`Using GitHub token from ${tokenSource}.`); | |
| console.error(`Fetching write+ collaborators for ${options.owner}/${options.repo}...`); | |
| } | |
| const contributors = await listWriteCollaborators(octokit, options); | |
| if (options.verbose) { | |
| console.error(`Scanning PR activity since ${formatDate(since)}...`); | |
| } | |
| const stats = await scanPullRequests(octokit, options, since, contributors); | |
| const inactiveContributors = [...contributors.values()] | |
| .filter( | |
| (contributor) => | |
| contributor.openedPrs.size === 0 && contributor.reviewedPrs.size === 0, | |
| ) | |
| .sort((a, b) => a.login.localeCompare(b.login)); | |
| if (options.json) { | |
| printJson(options, since, stats, contributors, inactiveContributors); | |
| } else { | |
| printText(options, since, stats, contributors, inactiveContributors); | |
| if (options.interactive) { | |
| const results = await promptForPermissionActions( | |
| octokit, | |
| options, | |
| inactiveContributors, | |
| ); | |
| printPermissionActionSummary(results); | |
| } | |
| } | |
| } | |
| main().catch((error) => { | |
| const details = errorMessage(error); | |
| fail(details); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment