Skip to content

Instantly share code, notes, and snippets.

@ElijahLynn
Last active November 13, 2025 22:06
Show Gist options
  • Select an option

  • Save ElijahLynn/8c635b2966aa52464dc2db3d23336959 to your computer and use it in GitHub Desktop.

Select an option

Save ElijahLynn/8c635b2966aa52464dc2db3d23336959 to your computer and use it in GitHub Desktop.
GitHub notification spam remover script by bejamincburns

Usage

Prerequisites

Basic Usage

Process all notifications from ghost repositories (deleted/missing repositories):

node github-notification-spam-remover.js

Process only notifications updated since a specific date:

node github-notification-spam-remover.js --since 2025-01-01

Options

  • --since <date> - Only process notifications updated since this date

    • Date format: YYYY-MM-DD (e.g., 2025-01-01)
    • If not provided, processes all available notifications
  • --help - Show help message and exit

Examples

Process all notifications from ghost repositories:

node github-notification-spam-remover.js

Only process notifications from ghost repositories updated since January 1, 2025:

node github-notification-spam-remover.js --since 2025-01-01

Example Output

$ node github-notification-spam-remover.js --since 2025-11-01

Fetching notifications since 2025-11-01...
Fetching notifications...
  Fetching page 1...
  Fetched 18 notifications from page 1 (total: 18)
Finished fetching 18 total notifications

Found 18 notifications

Checking notification from repo example-org/example-repo...
  Repository example-org/example-repo exists: true

Checking notification from repo deleted-org/ghost-repo...
  Repository deleted-org/ghost-repo exists: false
Marking notification with thread URL https://api.github.com/notifications/threads/123 read from repo deleted-org/ghost-repo
Marking notification with thread URL https://api.github.com/notifications/threads/123 done from repo deleted-org/ghost-repo
Unsubscribing from notification with thread URL https://api.github.com/notifications/threads/123 from repo deleted-org/ghost-repo

Done

What It Does

The script:

  1. Fetches all your GitHub notifications (with pagination support)
  2. Checks each notification's repository to see if it still exists
  3. For notifications from deleted/ghost repositories (404 status):
    • Marks the notification as read
    • Marks the notification as done
    • Unsubscribes from future notifications

Features

  • Pagination support: Fetches all pages of notifications (not just the first 50)
  • Caching: Repository existence checks are cached to avoid redundant API calls
  • Progress logging: Shows progress while fetching and processing notifications
  • Debug output: Displays which notifications

ORGINALLY FROM

Originally from @benjamincburns @ from https://github.com/orgs/community/discussions/6874?sort=new#discussioncomment-14481926


Here's a simple Node JS script that automates the process of detecting phantom notifications and then walking these "ghost notifications" through the read, done, unsubscribe states. To run it, just save it as a .cjs file somewhere and pass the since parameter as the first argument, formatted as a proper ISO 8601 timestamp.

Example execution:

node remove_phantom_notifications.cjs 2025-09-01T00:00:00Z To determine if a notification is from a banned account it first tries fetching the notification's repo details from the GitHub API. If that operation results in a 404, we know that the notification is from a banned account.

I should also note that you'll need the GitHub CLI installed in order for it to grab an auth token so it can authenticate its API requests. If you prefer to use a PAT, you can easily modify the getGithubToken function to return the token however you prefer (e.g. from process.env.GITHUB_TOKEN if you prefer an env var), or just hard code it into the script by replacing the line let _githubToken = null; with let githubToken = "ght..."; // your PAT here.

You'll need a classic PAT with repo and notifications permissions. It needs repo because otherwise it can't distinguish between notifications from private repositories and notifications from banned repositories. It does not perform any repository-level write operations. Unfortunately I couldn't find a way to get it working with a fine-grained PAT.

Edit: Initially this script only marked the notification as "done," but for good measure I've updated it to first mark the notification read, then mark it done, then unsubscribe. I suspect only the "done" or "unsubscribe" step are strictly necessary, but I figured I'd do all three just in case.

// from https://github.com/orgs/community/discussions/6874?sort=new#discussioncomment-14481926 (benjamincburns)
// https://gist.github.com/ElijahLynn/8c635b2966aa52464dc2db3d23336959
// modified to: add pagination support to fetch all notifications (not just first 50), cache repository existence checks,
// add progress logging during fetch, add debug logging with cached result indicators, format output with newlines,
// change from positional argument to --since option, add --help flag, and trim GitHub token to remove newlines
const { exec } = require("node:child_process");
const { basename } = require("node:path");
function runShellCommand(command) {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject({ error, stderr });
return;
}
resolve(stdout);
});
});
}
let _githubToken = null;
async function getGithubToken() {
if (!_githubToken) {
_githubToken = await runShellCommand("gh auth token");
// trim any whitespace/newlines from the token
_githubToken = _githubToken.trim();
}
return _githubToken;
}
async function getNotifications(since) {
const url = since
? `https://api.github.com/notifications?all=true&since=${since}`
: `https://api.github.com/notifications?all=true`;
console.log("Fetching notifications...");
let allNotifications = [];
let nextUrl = url;
let pageNum = 1;
while (nextUrl) {
console.log(` Fetching page ${pageNum}...`);
const response = await fetch(nextUrl, {
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${await getGithubToken()}`,
'X-GitHub-Api-Version': '2022-11-28',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch notifications: ${response.status} ${response.statusText}`);
}
const notifications = await response.json();
allNotifications = allNotifications.concat(notifications);
console.log(` Fetched ${notifications.length} notifications from page ${pageNum} (total: ${allNotifications.length})`);
// check for pagination link in Link header
const linkHeader = response.headers.get('Link');
if (linkHeader) {
// Link header format: <url>; rel="next", <url>; rel="last"
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
nextUrl = nextMatch ? nextMatch[1] : null;
} else {
nextUrl = null;
}
pageNum++;
}
console.log(`Finished fetching ${allNotifications.length} total notifications\n`);
return allNotifications;
}
// cache for repository existence checks
const repoExistenceCache = new Map();
async function shouldIncludeNotificationForRemoval(notification) {
const repoName = notification.repository.full_name;
// check cache first
if (repoExistenceCache.has(repoName)) {
return { shouldRemove: repoExistenceCache.get(repoName), cached: true };
}
try {
const response = await fetch(`https://api.github.com/repos/${repoName}`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${await getGithubToken()}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
const isGhostRepo = response.status === 404;
// cache the result
repoExistenceCache.set(repoName, isGhostRepo);
return { shouldRemove: isGhostRepo, cached: false };
} catch (error) {
console.log("threw");
if (error.code && error.code === 404) {
repoExistenceCache.set(repoName, true);
return { shouldRemove: true, cached: false };
}
console.error(error);
throw error;
}
}
async function markNotificationRead(notification) {
const response = await fetch(notification.url, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${await getGithubToken()}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
console.error(`Failed to mark notification with thread URL ${notification.url} from repo ${notification.repository.full_name} as read: ${response.status} ${response.statusText}`);
}
}
async function markNotificationDone(notification) {
const response = await fetch(notification.url, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${await getGithubToken()}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
console.error(`Failed to mark notification with thread URL ${notification.url} from repo ${notification.repository.full_name} as done: ${response.status} ${response.statusText}`);
}
}
async function unsubscribe(notification) {
const response = await fetch(notification.subscription_url, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${await getGithubToken()}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
console.error(`Failed to unsubscribe from notification with thread URL ${notification.url} from repo ${notification.repository.full_name}: ${response.status} ${response.statusText}`);
}
}
function showHelp() {
console.log(`Usage: ${basename(process.argv[1])} [--since <date>] [--help]
Mark all GitHub notifications from deleted/ghost repositories as read, done, and unsubscribe.
Options:
--since <date> Only process notifications updated since this date.
Date format: YYYY-MM-DD (e.g., 2025-01-01)
If not provided, processes all available notifications.
--help Show this help message and exit.
Examples:
${basename(process.argv[1])}
Process all notifications from ghost repositories.
${basename(process.argv[1])} --since 2025-01-01
Only process notifications from ghost repositories updated since January 1, 2025.
Notes:
- Requires GitHub CLI (gh) to be installed and authenticated, https://cli.github.com/
- The script fetches all pages of notifications (not just the first 50).
- Repository existence checks are cached to avoid redundant API calls.
`);
}
async function main() {
let since = null;
// check for --help flag
if (process.argv.includes('--help') || process.argv.includes('-h')) {
showHelp();
process.exit(0);
}
// parse --since option
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === '--since') {
if (i + 1 < process.argv.length) {
since = process.argv[i + 1];
} else {
console.error('Error: --since requires a value');
showHelp();
process.exit(1);
}
break;
}
}
// validate date if provided
if (since) {
try {
new Date(since);
} catch (error) {
console.error(`${since} is not a valid date. Must be formatted as YYYY-MM-DD.`);
showHelp();
process.exit(1);
}
}
if (since) {
console.log(`Fetching notifications since ${since}...`);
} else {
console.log('Fetching all notifications (no --since filter)...');
}
const notifications = await getNotifications(since);
console.log(`Found ${notifications.length} notifications\n`);
for (const notification of notifications) {
console.log(`Checking notification from repo ${notification.repository.full_name}...`);
const { shouldRemove, cached } = await shouldIncludeNotificationForRemoval(notification);
const cachedText = cached ? " (cached result)" : "";
console.log(` Repository ${notification.repository.full_name} exists: ${!shouldRemove}${cachedText}`);
if (shouldRemove) {
console.log(`Marking notification with thread URL ${notification.url} read from repo ${notification.repository.full_name}`);
await markNotificationRead(notification);
console.log(`Marking notification with thread URL ${notification.url} done from repo ${notification.repository.full_name}`);
await markNotificationDone(notification);
console.log(`Unsubscribing from notification with thread URL ${notification.url} from repo ${notification.repository.full_name}`);
await unsubscribe(notification);
}
console.log(); // add newline between results
}
console.log("Done");
}
main().catch(console.error);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment