-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
16 changed files
with
1,020 additions
and
0 deletions.
There are no files selected for viewing
This file contains 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
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 "$@" |
This file contains 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
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); | ||
} |
This file contains 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
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(); | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export class NotificationCleanup { | ||
} |
This file contains 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
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 | ||
}); | ||
} | ||
} |
This file contains 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
This file contains 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
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]); | ||
}); | ||
}); |
Oops, something went wrong.