Skip to content

Instantly share code, notes, and snippets.

@louwers
Created July 9, 2026 13:22
Show Gist options
  • Select an option

  • Save louwers/a7aa20e4fa474d907c552c4325c529ea to your computer and use it in GitHub Desktop.

Select an option

Save louwers/a7aa20e4fa474d907c552c4325c529ea to your computer and use it in GitHub Desktop.
#!/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