Skip to content

Commit

Permalink
feat: initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
awendt committed Nov 9, 2024
1 parent 739e2e2 commit 350a461
Show file tree
Hide file tree
Showing 16 changed files with 1,020 additions and 0 deletions.
10 changes: 10 additions & 0 deletions gh-cleanup-notifications
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e

# Determine if an executable is in the PATH
if ! type -p node >/dev/null; then
echo "Node not found on the system" >&2
exit 1
fi

exec node index.js "$@"
60 changes: 60 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { GitHubClient } from "./lib/client.js";
import { NotificationReducer } from "./lib/notification-reducer.js";

const DEFAULT_INTERVAL = 60;

let options = process.argv
.slice(2)
.filter(arg => arg.startsWith("--"))
.map(arg => arg.slice(2))
.reduce((acc, arg) => {
const [option, value] = arg.indexOf("=") === -1 ? [arg, true] : arg.split("=");
return Object.assign(acc, { [option.replaceAll(/-([a-z])/gi, (_match, char, ..._args) => char.toUpperCase())]: value });
}, {});

console.debug(options);

const github = new GitHubClient(options);
const me = await github.me();

const doWork = async () => {
const notifications = await github.notifications();

const reducer = new NotificationReducer({ notifications, me });
reducer.pullRequests = await github.batchRequests(reducer.pullNotifications.map(n => n.subject.url));

// Case 1: notifications for closed PRs => marking as done
if (options.cleanupClosedPrs) {
const notificationsForClosedPRs = reducer.notificationsForClosedPRs;
console.debug("%d notifications for closed PRs, marking as done…", notificationsForClosedPRs.length);
notificationsForClosedPRs.forEach(notification => console.debug(notification.pull_request.html_url));
await github.markAsDone(notificationsForClosedPRs);
}

// Case 2: subscribed but someone else already assigned
if (options.cleanupReassignedPrs) {
const someoneElseAssigned = reducer.notificationsForReassignedPRs;
console.debug("%d notifications for PRs assigned to someone else, unsubscribing…", someoneElseAssigned.length);
await github.unsubscribe(someoneElseAssigned);
}

// Case 3: review requested but no reviews pending
if (options.cleanupReviewPrs) {
const reviewRequestedAndReviewed = reducer.notificationsForReviewedPRs;
console.debug("%d notifications for PRs requesting and gotten reviews, unsubscribing…", reviewRequestedAndReviewed.length);
await github.unsubscribe(reviewRequestedAndReviewed);
}
}

const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

while(true) {
if (await github.hasNewNotifications()) {
await doWork();
} else {
console.debug("No new notifications");
}

const interval = github.pollInterval || DEFAULT_INTERVAL;
await delay(interval * 1000);
}
118 changes: 118 additions & 0 deletions lib/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
export class GitHubClient {
constructor({ verbose, dryRun, batchSignal }) {
this.requiredHeaders = {
'User-Agent': 'github.com/awendt/dotfiles',
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`
}
this.verbose = verbose;
this.dryRun = dryRun;
this.lastModifiedHeaders = {};
this.batchSignal = batchSignal || (() => {});
}

async me() {
const json = await this.fetch("https://api.github.com/user");
return json.login;
}

async hasNewNotifications() {
const url = 'https://api.github.com/notifications';
const lastModified = this.lastModifiedHeaders[url];
if (lastModified) {
const headers = { 'If-Modified-Since': lastModified };
// We are not using this.fetch because it already does too much
const response = await fetch(url, {
method: 'HEAD',
headers: Object.assign({}, headers, this.requiredHeaders)
});
this.pollInterval = response.headers.get('x-poll-interval');
if (response.status === 304) { return false; }
if (response.status === 200) { return true; }

throw new Error(`Unexpected response status: ${response.status} ${response.statusText}`);
}
return true;
}

async notifications() {
const url = "https://api.github.com/notifications";
const callback = (headers) => {
if (headers.has('Last-Modified')) {
this.lastModifiedHeaders[url] = headers.get('Last-Modified');
}
};

return await this.fetch(url, {}, callback);
}

async batchRequests(urls, { method, headers } = {}) {
const BATCH_SIZE = 10;
const bag = urls.slice(0); // operate on a shallow copy
let responses = [];

while(bag.length > 0) {
let batch = bag.splice(0, BATCH_SIZE);
this.batchSignal(batch.length);

responses = responses.concat(await this.parallelRequests(batch, { method, headers }));
}
return responses;
}

async parallelRequests(urls, { method, headers }) {
return Promise.all(
urls.map(async url => {
return await this.fetch(url, { method, headers });
})
)
}

async markAsDone(notifications) {
if (this.dryRun) { return; }

if (notifications.length > 0) {
return this.batchRequests(notifications.map(notification => notification.url), { method: 'DELETE'});
}
}

async unsubscribe(notifications) {
if (this.dryRun) { return; }

await this.batchRequests(notifications.flatMap(notification => notification.subscription_url), { method: 'DELETE'});
await this.markAsDone(notifications);
}

async fetch(url, { method, headers } = {}, responseHeadersCallback) {
const nextPattern = /(?<=<)([\S]*)(?=>; rel="Next")/i;
let pagesRemaining = true;
let data = [];

while(pagesRemaining) {
const response = await fetch(url, { method, headers: Object.assign({}, headers, this.requiredHeaders) });

if (!response.ok) {
throw new Error(`Response status: ${response.status} ${response.statusText}`);
}

if (responseHeadersCallback) { responseHeadersCallback(response.headers); }

if (this.verbose) { console.debug(" ", response.status, response.statusText, url); }

if (response.status == 204) { return null; }

// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
const linkHeader = response.headers.get("Link");
pagesRemaining = linkHeader && linkHeader.includes(`rel=\"next\"`);
if (pagesRemaining) {
url = linkHeader.match(nextPattern)[0];
}

let parsed = await response.json();
data.push(parsed);
}

if (data.length === 1) { return data[0]; }

return data.flat();
}
}
2 changes: 2 additions & 0 deletions lib/notification-cleanup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export class NotificationCleanup {
}
34 changes: 34 additions & 0 deletions lib/notification-reducer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export class NotificationReducer {
notificationsWithPullRequests = [];

constructor({ notifications, me }) {
this.pullNotifications = notifications.filter(notification => notification.subject.type === "PullRequest");
this.me = me;
}

set pullRequests(list) {
this.notificationsWithPullRequests = this.pullNotifications.reduce((acc, notification) => {
// Find information about PR
const pullRequest = list.find(pr => pr.url === notification.subject.url);
acc.push(Object.assign({}, notification, { pull_request: pullRequest }));
return acc;
}, []);
}

get notificationsForClosedPRs() {
return this.notificationsWithPullRequests.filter(notification => notification.pull_request.state === "closed");
}

get notificationsForReassignedPRs() {
return this.notificationsWithPullRequests.filter((notification) => {
return notification.reason === "subscribed" && notification.pull_request.assignee != this.me && notification.pull_request.assignee != ""
});
}

get notificationsForReviewedPRs() {
return this.notificationsWithPullRequests.filter((notification) => {
const pendingReviews = notification.pull_request.requested_reviewers.length + notification.pull_request.requested_teams.length;
return notification.reason === "review_requested" && pendingReviews === 0
});
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "1.0.0",
"description": "gh cli extension to clean up notifications in GitHub",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
Expand Down
53 changes: 53 additions & 0 deletions test/batch-requests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from 'node:assert';
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import { FetchInterceptor } from '@mswjs/interceptors/fetch'
import { GitHubClient } from '../lib/client.js';

const interceptor = new FetchInterceptor();

describe('#batchRequests', async () => {
beforeEach(() => {
interceptor.apply();

interceptor.on('unhandledException', ({ error }) => console.log(error));

interceptor.on('request', async ({ request, controller }) => {
switch(request.url) {
case "https://api.github.com/batch1":
case "https://api.github.com/batch2":
controller.respondWith(Response.json(
{ url: request.url, time: new Date() },
{ status: 200 }
));
break;
default:
controller.errorWith(new Error(`No handler for URL ${request.url} defined`));
}
});
});
afterEach(() => interceptor.dispose());

it('runs up to 10 requests in parallel', async () => {
const batchSignal = mock.fn();
const github = new GitHubClient({ batchSignal });

await github.batchRequests([
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch1',
'https://api.github.com/batch2',
]);

// There are 2 batches, the first with 10 URLs, the second with 1 URL
assert.strictEqual(batchSignal.mock.callCount(), 2);
assert.deepEqual(batchSignal.mock.calls[0].arguments, [10]);
assert.deepEqual(batchSignal.mock.calls[1].arguments, [1]);
});
});
Loading

0 comments on commit 350a461

Please sign in to comment.