From 350a461374d16f78e532bf7759458493e3e98441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Wendt?= Date: Mon, 14 Oct 2024 22:22:36 +0200 Subject: [PATCH] feat: initial version --- gh-cleanup-notifications | 10 ++ index.js | 60 ++++++++ lib/client.js | 118 +++++++++++++++ lib/notification-cleanup.js | 2 + lib/notification-reducer.js | 34 +++++ package.json | 1 + test/batch-requests.js | 53 +++++++ test/fixtures/notifications.json | 248 +++++++++++++++++++++++++++++++ test/fixtures/pr21.json | 35 +++++ test/fixtures/pr45.json | 35 +++++ test/has-new-notifications.js | 112 ++++++++++++++ test/me.js | 34 +++++ test/notifications.js | 84 +++++++++++ test/options.js | 47 ++++++ test/reducer.js | 87 +++++++++++ test/unsubscribe.js | 60 ++++++++ 16 files changed, 1020 insertions(+) create mode 100755 gh-cleanup-notifications create mode 100755 index.js create mode 100644 lib/client.js create mode 100644 lib/notification-cleanup.js create mode 100644 lib/notification-reducer.js create mode 100644 test/batch-requests.js create mode 100644 test/fixtures/notifications.json create mode 100644 test/fixtures/pr21.json create mode 100644 test/fixtures/pr45.json create mode 100644 test/has-new-notifications.js create mode 100644 test/me.js create mode 100644 test/notifications.js create mode 100644 test/options.js create mode 100644 test/reducer.js create mode 100644 test/unsubscribe.js diff --git a/gh-cleanup-notifications b/gh-cleanup-notifications new file mode 100755 index 0000000..b9a59fc --- /dev/null +++ b/gh-cleanup-notifications @@ -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 "$@" diff --git a/index.js b/index.js new file mode 100755 index 0000000..4de108c --- /dev/null +++ b/index.js @@ -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); +} diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..86bdde6 --- /dev/null +++ b/lib/client.js @@ -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(); + } +} diff --git a/lib/notification-cleanup.js b/lib/notification-cleanup.js new file mode 100644 index 0000000..cad6777 --- /dev/null +++ b/lib/notification-cleanup.js @@ -0,0 +1,2 @@ +export class NotificationCleanup { +} diff --git a/lib/notification-reducer.js b/lib/notification-reducer.js new file mode 100644 index 0000000..b1f5c85 --- /dev/null +++ b/lib/notification-reducer.js @@ -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 + }); + } +} diff --git a/package.json b/package.json index 38ef0a2..be39a02 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/test/batch-requests.js b/test/batch-requests.js new file mode 100644 index 0000000..2b03ff1 --- /dev/null +++ b/test/batch-requests.js @@ -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]); + }); +}); diff --git a/test/fixtures/notifications.json b/test/fixtures/notifications.json new file mode 100644 index 0000000..a7270ed --- /dev/null +++ b/test/fixtures/notifications.json @@ -0,0 +1,248 @@ +[ + { + "id": "10868937921", + "unread": true, + "reason": "subscribed", + "updated_at": "2024-09-20T16:53:12Z", + "last_read_at": "2024-06-18T07:47:55Z", + "subject": { + "title": "Secret scanning detects secrets in GitHub discussions and pull request content", + "url": "https://api.github.com/repos/github/roadmap/issues/965", + "latest_comment_url": "https://api.github.com/repos/github/roadmap/issues/comments/2364134567", + "type": "Issue" + }, + "repository": { + "id": 251747948, + "node_id": "MDEwOlJlcG9zaXRvcnkyNTE3NDc5NDg=", + "name": "roadmap", + "full_name": "github/roadmap", + "private": false, + "owner": { + "login": "github", + "id": 9919, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjk5MTk=", + "avatar_url": "https://avatars.githubusercontent.com/u/9919?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github", + "html_url": "https://github.com/github", + "followers_url": "https://api.github.com/users/github/followers", + "following_url": "https://api.github.com/users/github/following{/other_user}", + "gists_url": "https://api.github.com/users/github/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github/subscriptions", + "organizations_url": "https://api.github.com/users/github/orgs", + "repos_url": "https://api.github.com/users/github/repos", + "events_url": "https://api.github.com/users/github/events{/privacy}", + "received_events_url": "https://api.github.com/users/github/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/github/roadmap", + "description": "GitHub public roadmap", + "fork": false, + "url": "https://api.github.com/repos/github/roadmap", + "forks_url": "https://api.github.com/repos/github/roadmap/forks", + "keys_url": "https://api.github.com/repos/github/roadmap/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/github/roadmap/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/github/roadmap/teams", + "hooks_url": "https://api.github.com/repos/github/roadmap/hooks", + "issue_events_url": "https://api.github.com/repos/github/roadmap/issues/events{/number}", + "events_url": "https://api.github.com/repos/github/roadmap/events", + "assignees_url": "https://api.github.com/repos/github/roadmap/assignees{/user}", + "branches_url": "https://api.github.com/repos/github/roadmap/branches{/branch}", + "tags_url": "https://api.github.com/repos/github/roadmap/tags", + "blobs_url": "https://api.github.com/repos/github/roadmap/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/github/roadmap/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/github/roadmap/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/github/roadmap/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/github/roadmap/statuses/{sha}", + "languages_url": "https://api.github.com/repos/github/roadmap/languages", + "stargazers_url": "https://api.github.com/repos/github/roadmap/stargazers", + "contributors_url": "https://api.github.com/repos/github/roadmap/contributors", + "subscribers_url": "https://api.github.com/repos/github/roadmap/subscribers", + "subscription_url": "https://api.github.com/repos/github/roadmap/subscription", + "commits_url": "https://api.github.com/repos/github/roadmap/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/github/roadmap/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/github/roadmap/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/github/roadmap/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/github/roadmap/contents/{+path}", + "compare_url": "https://api.github.com/repos/github/roadmap/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/github/roadmap/merges", + "archive_url": "https://api.github.com/repos/github/roadmap/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/github/roadmap/downloads", + "issues_url": "https://api.github.com/repos/github/roadmap/issues{/number}", + "pulls_url": "https://api.github.com/repos/github/roadmap/pulls{/number}", + "milestones_url": "https://api.github.com/repos/github/roadmap/milestones{/number}", + "notifications_url": "https://api.github.com/repos/github/roadmap/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/github/roadmap/labels{/name}", + "releases_url": "https://api.github.com/repos/github/roadmap/releases{/id}", + "deployments_url": "https://api.github.com/repos/github/roadmap/deployments" + }, + "url": "https://api.github.com/notifications/threads/10868937921", + "subscription_url": "https://api.github.com/notifications/threads/10868937921/subscription" + }, + { + "id": "12536401926", + "unread": true, + "reason": "subscribed", + "updated_at": "2024-09-24T08:34:46Z", + "last_read_at": "2024-09-24T08:20:49Z", + "subject": { + "title": "Breaking change: Unify support for resource-specific tags", + "url": "https://api.github.com/repos/babbel/terraform-aws-athena/pulls/45", + "latest_comment_url": null, + "type": "PullRequest" + }, + "repository": { + "id": 344860693, + "node_id": "MDEwOlJlcG9zaXRvcnkzNDQ4NjA2OTM=", + "name": "terraform-aws-athena", + "full_name": "babbel/terraform-aws-athena", + "private": false, + "owner": { + "login": "babbel", + "id": 2762251, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI3NjIyNTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/2762251?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/babbel", + "html_url": "https://github.com/babbel", + "followers_url": "https://api.github.com/users/babbel/followers", + "following_url": "https://api.github.com/users/babbel/following{/other_user}", + "gists_url": "https://api.github.com/users/babbel/gists{/gist_id}", + "starred_url": "https://api.github.com/users/babbel/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/babbel/subscriptions", + "organizations_url": "https://api.github.com/users/babbel/orgs", + "repos_url": "https://api.github.com/users/babbel/repos", + "events_url": "https://api.github.com/users/babbel/events{/privacy}", + "received_events_url": "https://api.github.com/users/babbel/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/babbel/terraform-aws-athena", + "description": "Terraform module creating a Glue table, an Athena workgroup and an S3 bucket for the workgroup", + "fork": false, + "url": "https://api.github.com/repos/babbel/terraform-aws-athena", + "forks_url": "https://api.github.com/repos/babbel/terraform-aws-athena/forks", + "keys_url": "https://api.github.com/repos/babbel/terraform-aws-athena/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/babbel/terraform-aws-athena/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/babbel/terraform-aws-athena/teams", + "hooks_url": "https://api.github.com/repos/babbel/terraform-aws-athena/hooks", + "issue_events_url": "https://api.github.com/repos/babbel/terraform-aws-athena/issues/events{/number}", + "events_url": "https://api.github.com/repos/babbel/terraform-aws-athena/events", + "assignees_url": "https://api.github.com/repos/babbel/terraform-aws-athena/assignees{/user}", + "branches_url": "https://api.github.com/repos/babbel/terraform-aws-athena/branches{/branch}", + "tags_url": "https://api.github.com/repos/babbel/terraform-aws-athena/tags", + "blobs_url": "https://api.github.com/repos/babbel/terraform-aws-athena/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/babbel/terraform-aws-athena/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/babbel/terraform-aws-athena/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/babbel/terraform-aws-athena/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/babbel/terraform-aws-athena/statuses/{sha}", + "languages_url": "https://api.github.com/repos/babbel/terraform-aws-athena/languages", + "stargazers_url": "https://api.github.com/repos/babbel/terraform-aws-athena/stargazers", + "contributors_url": "https://api.github.com/repos/babbel/terraform-aws-athena/contributors", + "subscribers_url": "https://api.github.com/repos/babbel/terraform-aws-athena/subscribers", + "subscription_url": "https://api.github.com/repos/babbel/terraform-aws-athena/subscription", + "commits_url": "https://api.github.com/repos/babbel/terraform-aws-athena/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/babbel/terraform-aws-athena/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/babbel/terraform-aws-athena/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/babbel/terraform-aws-athena/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/babbel/terraform-aws-athena/contents/{+path}", + "compare_url": "https://api.github.com/repos/babbel/terraform-aws-athena/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/babbel/terraform-aws-athena/merges", + "archive_url": "https://api.github.com/repos/babbel/terraform-aws-athena/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/babbel/terraform-aws-athena/downloads", + "issues_url": "https://api.github.com/repos/babbel/terraform-aws-athena/issues{/number}", + "pulls_url": "https://api.github.com/repos/babbel/terraform-aws-athena/pulls{/number}", + "milestones_url": "https://api.github.com/repos/babbel/terraform-aws-athena/milestones{/number}", + "notifications_url": "https://api.github.com/repos/babbel/terraform-aws-athena/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/babbel/terraform-aws-athena/labels{/name}", + "releases_url": "https://api.github.com/repos/babbel/terraform-aws-athena/releases{/id}", + "deployments_url": "https://api.github.com/repos/babbel/terraform-aws-athena/deployments" + }, + "url": "https://api.github.com/notifications/threads/12536401926", + "subscription_url": "https://api.github.com/notifications/threads/12536401926/subscription" + }, + { + "id": "12536398051", + "unread": true, + "reason": "review_requested", + "updated_at": "2024-09-24T08:33:42Z", + "last_read_at": "2024-09-24T08:20:46Z", + "subject": { + "title": "Add support for resource-specific tags", + "url": "https://api.github.com/repos/babbel/terraform-aws-acm/pulls/21", + "latest_comment_url": null, + "type": "PullRequest" + }, + "repository": { + "id": 447128067, + "node_id": "R_kgDOGqaiAw", + "name": "terraform-aws-acm", + "full_name": "babbel/terraform-aws-acm", + "private": false, + "owner": { + "login": "babbel", + "id": 2762251, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjI3NjIyNTE=", + "avatar_url": "https://avatars.githubusercontent.com/u/2762251?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/babbel", + "html_url": "https://github.com/babbel", + "followers_url": "https://api.github.com/users/babbel/followers", + "following_url": "https://api.github.com/users/babbel/following{/other_user}", + "gists_url": "https://api.github.com/users/babbel/gists{/gist_id}", + "starred_url": "https://api.github.com/users/babbel/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/babbel/subscriptions", + "organizations_url": "https://api.github.com/users/babbel/orgs", + "repos_url": "https://api.github.com/users/babbel/repos", + "events_url": "https://api.github.com/users/babbel/events{/privacy}", + "received_events_url": "https://api.github.com/users/babbel/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/babbel/terraform-aws-acm", + "description": "Terraform module creating an ACM certificate", + "fork": false, + "url": "https://api.github.com/repos/babbel/terraform-aws-acm", + "forks_url": "https://api.github.com/repos/babbel/terraform-aws-acm/forks", + "keys_url": "https://api.github.com/repos/babbel/terraform-aws-acm/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/babbel/terraform-aws-acm/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/babbel/terraform-aws-acm/teams", + "hooks_url": "https://api.github.com/repos/babbel/terraform-aws-acm/hooks", + "issue_events_url": "https://api.github.com/repos/babbel/terraform-aws-acm/issues/events{/number}", + "events_url": "https://api.github.com/repos/babbel/terraform-aws-acm/events", + "assignees_url": "https://api.github.com/repos/babbel/terraform-aws-acm/assignees{/user}", + "branches_url": "https://api.github.com/repos/babbel/terraform-aws-acm/branches{/branch}", + "tags_url": "https://api.github.com/repos/babbel/terraform-aws-acm/tags", + "blobs_url": "https://api.github.com/repos/babbel/terraform-aws-acm/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/babbel/terraform-aws-acm/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/babbel/terraform-aws-acm/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/babbel/terraform-aws-acm/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/babbel/terraform-aws-acm/statuses/{sha}", + "languages_url": "https://api.github.com/repos/babbel/terraform-aws-acm/languages", + "stargazers_url": "https://api.github.com/repos/babbel/terraform-aws-acm/stargazers", + "contributors_url": "https://api.github.com/repos/babbel/terraform-aws-acm/contributors", + "subscribers_url": "https://api.github.com/repos/babbel/terraform-aws-acm/subscribers", + "subscription_url": "https://api.github.com/repos/babbel/terraform-aws-acm/subscription", + "commits_url": "https://api.github.com/repos/babbel/terraform-aws-acm/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/babbel/terraform-aws-acm/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/babbel/terraform-aws-acm/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/babbel/terraform-aws-acm/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/babbel/terraform-aws-acm/contents/{+path}", + "compare_url": "https://api.github.com/repos/babbel/terraform-aws-acm/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/babbel/terraform-aws-acm/merges", + "archive_url": "https://api.github.com/repos/babbel/terraform-aws-acm/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/babbel/terraform-aws-acm/downloads", + "issues_url": "https://api.github.com/repos/babbel/terraform-aws-acm/issues{/number}", + "pulls_url": "https://api.github.com/repos/babbel/terraform-aws-acm/pulls{/number}", + "milestones_url": "https://api.github.com/repos/babbel/terraform-aws-acm/milestones{/number}", + "notifications_url": "https://api.github.com/repos/babbel/terraform-aws-acm/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/babbel/terraform-aws-acm/labels{/name}", + "releases_url": "https://api.github.com/repos/babbel/terraform-aws-acm/releases{/id}", + "deployments_url": "https://api.github.com/repos/babbel/terraform-aws-acm/deployments" + }, + "url": "https://api.github.com/notifications/threads/12536398051", + "subscription_url": "https://api.github.com/notifications/threads/12536398051/subscription" + } +] diff --git a/test/fixtures/pr21.json b/test/fixtures/pr21.json new file mode 100644 index 0000000..8b93dca --- /dev/null +++ b/test/fixtures/pr21.json @@ -0,0 +1,35 @@ +{ + "url": "https://api.github.com/repos/babbel/terraform-aws-acm/pulls/21", + "html_url": "https://github.com/babbel/terraform-aws-acm/pull/21", + "number": 21, + "state": "closed", + "title": "Add support for resource-specific tags", + "user": { + "login": "jansiwy", + "id": 610800, + "node_id": "MDQ6VXNlcjYxMDgwMA==", + "avatar_url": "https://avatars.githubusercontent.com/u/610800?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/jansiwy", + "html_url": "https://github.com/jansiwy", + "followers_url": "https://api.github.com/users/jansiwy/followers", + "following_url": "https://api.github.com/users/jansiwy/following{/other_user}", + "gists_url": "https://api.github.com/users/jansiwy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jansiwy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jansiwy/subscriptions", + "organizations_url": "https://api.github.com/users/jansiwy/orgs", + "repos_url": "https://api.github.com/users/jansiwy/repos", + "events_url": "https://api.github.com/users/jansiwy/events{/privacy}", + "received_events_url": "https://api.github.com/users/jansiwy/received_events", + "type": "User", + "site_admin": false + }, + "body": null, + "created_at": "2024-09-23T16:30:07Z", + "updated_at": "2024-09-24T09:03:08Z", + "closed_at": "2024-09-24T09:03:06Z", + "merged_at": "2024-09-24T09:03:06Z", + "merge_commit_sha": "123850dfbeddd3db19f572483e27ff2c1ce35527", + "requested_reviewers": [], + "requested_teams": [] +} diff --git a/test/fixtures/pr45.json b/test/fixtures/pr45.json new file mode 100644 index 0000000..6bf8f70 --- /dev/null +++ b/test/fixtures/pr45.json @@ -0,0 +1,35 @@ +{ + "url": "https://api.github.com/repos/babbel/terraform-aws-athena/pulls/45", + "html_url": "https://github.com/babbel/terraform-aws-athena/pull/45", + "number": 45, + "state": "open", + "title": "Breaking change: Unify support for resource-specific tags", + "user": { + "login": "jansiwy", + "id": 610800, + "node_id": "MDQ6VXNlcjYxMDgwMA==", + "avatar_url": "https://avatars.githubusercontent.com/u/610800?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/jansiwy", + "html_url": "https://github.com/jansiwy", + "followers_url": "https://api.github.com/users/jansiwy/followers", + "following_url": "https://api.github.com/users/jansiwy/following{/other_user}", + "gists_url": "https://api.github.com/users/jansiwy/gists{/gist_id}", + "starred_url": "https://api.github.com/users/jansiwy/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/jansiwy/subscriptions", + "organizations_url": "https://api.github.com/users/jansiwy/orgs", + "repos_url": "https://api.github.com/users/jansiwy/repos", + "events_url": "https://api.github.com/users/jansiwy/events{/privacy}", + "received_events_url": "https://api.github.com/users/jansiwy/received_events", + "type": "User", + "site_admin": false + }, + "body": null, + "created_at": "2024-09-23T16:30:24Z", + "updated_at": "2024-09-24T09:02:27Z", + "closed_at": "2024-09-24T09:02:25Z", + "merged_at": "2024-09-24T09:02:24Z", + "merge_commit_sha": "bfc6d993ccd6fd88b7b293761e0f5c36f9bbe5c4", + "requested_reviewers": [], + "requested_teams": [] +} diff --git a/test/has-new-notifications.js b/test/has-new-notifications.js new file mode 100644 index 0000000..2d98b41 --- /dev/null +++ b/test/has-new-notifications.js @@ -0,0 +1,112 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { GitHubClient } from '../lib/client.js'; + +const interceptor = new FetchInterceptor(); + +let headResponse; + +describe('checking for new notifications', async () => { + beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + interceptor.on('request', ({ request, controller }) => { + switch(`${request.method} ${request.url}`) { + case "GET https://api.github.com/notifications": + controller.respondWith(Response.json( + [], + { + status: 200, + headers: { 'Last-Modified': 'Fri, 20 Sep 2024 08:53:07 GMT' } + } + )); + break; + case "HEAD https://api.github.com/notifications": + controller.respondWith(headResponse) + break; + default: + controller.errorWith(new Error(`No handler for URL ${request.url} defined`)); + } + }); + }); + afterEach(() => interceptor.dispose()); + + it('always has new notifications on first call', async () => { + const github = new GitHubClient({}); + + assert.equal(await github.hasNewNotifications(), true); + }); + + describe("after initially getting notifications", async () => { + it('stores the Last-Modified timestamp', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + assert.equal(github.lastModifiedHeaders['https://api.github.com/notifications'], 'Fri, 20 Sep 2024 08:53:07 GMT'); + }); + + describe('when HEAD request responds with 304', async () => { + beforeEach(() => { + headResponse = new Response(null, { status: 304, headers: { 'x-poll-interval': 123 } }); + }); + + it('has no new notifications', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + assert.equal(await github.hasNewNotifications(), false); + }); + + it('stores the poll interval', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + await github.hasNewNotifications(); + assert.equal(github.pollInterval, 123); + }); + }); + + describe('when HEAD request responds with 200', async () => { + beforeEach(() => { + headResponse = Response.json( [], { status: 200, headers: { 'X-Poll-Interval': 123 } }); + }); + + it('has new notifications', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + assert.equal(await github.hasNewNotifications(), true); + }); + + it('stores the poll interval', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + await github.hasNewNotifications(); + assert.equal(github.pollInterval, 123); + }); + }); + + describe('when HEAD request responds with anything else', async () => { + beforeEach(() => { + headResponse = Response.json( [], { status: 201 }); + }); + + it('throws an error', async () => { + const github = new GitHubClient({}); + + await github.notifications(); + await assert.rejects( + async () => { + await github.hasNewNotifications(); + }, + /Unexpected response status/ + ); + }); + }); + }); + +}); diff --git a/test/me.js b/test/me.js new file mode 100644 index 0000000..96d67b3 --- /dev/null +++ b/test/me.js @@ -0,0 +1,34 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { GitHubClient } from '../lib/client.js'; + +const interceptor = new FetchInterceptor(); + +describe('#me', async () => { + beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + interceptor.on('request', ({ request, controller }) => { + switch(request.url) { + case "https://api.github.com/user": + controller.respondWith(Response.json( + { login: "awendt", node_id: "MDQ6VXNlcjExOTY0" }, + { status: 200 } + )); + break; + default: + controller.errorWith(new Error(`No handler for URL ${request.url} defined`)); + } + }); + }); + afterEach(() => interceptor.dispose()); + + it('returns the login of the user', async () => { + const github = new GitHubClient({}); + + assert.equal(await github.me(), 'awendt'); + }); +}); diff --git a/test/notifications.js b/test/notifications.js new file mode 100644 index 0000000..3bb3896 --- /dev/null +++ b/test/notifications.js @@ -0,0 +1,84 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { GitHubClient } from '../lib/client.js'; + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createReadStream } from 'node:fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const interceptor = new FetchInterceptor(); + +describe('#notifications', async () => { + beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + interceptor.on('request', ({ request, controller }) => { + switch(request.url) { + case "https://api.github.com/notifications": + controller.respondWith(new Response( + createReadStream(path.join(__dirname, 'fixtures', 'notifications.json')), + { status: 200 } + )); + break; + default: + controller.errorWith(new Error(`No handler for URL ${request.url} defined`)); + } + }); + }); + afterEach(() => interceptor.dispose()); + + it('returns parsed JSON', async () => { + const github = new GitHubClient({}); + + const notifications = await github.notifications(); + assert.equal(notifications.length, 3); + assert.deepEqual(Object.keys(notifications[0]), [ 'id', 'unread', 'reason', 'updated_at', 'last_read_at', 'subject', 'repository', 'url', 'subscription_url' ]); + }); +}); + +describe('#notifications with pagination', async () => { + beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + interceptor.on('request', ({ request, controller }) => { + switch(request.url) { + case "https://api.github.com/notifications": + controller.respondWith(new Response( + createReadStream(path.join(__dirname, 'fixtures', 'notifications.json')), + { + status: 200, + headers: { 'Link': '; rel="next", ; rel="last", ; rel="first"' } + } + )); + break; + case "https://api.github.com/notifications?page=2": + controller.respondWith(new Response( + createReadStream(path.join(__dirname, 'fixtures', 'notifications.json')), + { + status: 200, + } + )); + break; + default: + controller.errorWith(new Error(`No handler for URL ${request.url} defined`)); + } + }); + }); + afterEach(() => interceptor.dispose()); + + it('returns results from all pages', async () => { + const github = new GitHubClient({}); + + const notifications = await github.notifications(); + assert.equal(notifications.length, 6); + assert.ok(notifications.every(notification => notification.unread)); + }); +}); diff --git a/test/options.js b/test/options.js new file mode 100644 index 0000000..808383d --- /dev/null +++ b/test/options.js @@ -0,0 +1,47 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { GitHubClient } from '../lib/client.js'; + +const interceptor = new FetchInterceptor(); + +describe('dryRun: true', async () => { + beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + interceptor.on('request', ({ request, controller }) => { + controller.respondWith(Response.error()) + }); + }); + afterEach(() => interceptor.dispose()); + + it('does not unsubscribe', async () => { + const github = new GitHubClient({ dryRun: true }); + + await assert.doesNotReject( + async () => { + await github.unsubscribe([ + { subscription_url: 'https://api.github.com/foo' }, + { subscription_url: 'https://api.github.com/bar' }, + ]); + }, + /Failed to fetch/ + ); + }); + + it('does not mark as done', async () => { + const github = new GitHubClient({ dryRun: true }); + + await assert.doesNotReject( + async () => { + await github.markAsDone([ + { url: 'https://api.github.com/foo' }, + { url: 'https://api.github.com/bar' }, + ]); + }, + /Failed to fetch/ + ); + }); +}); diff --git a/test/reducer.js b/test/reducer.js new file mode 100644 index 0000000..632f8f4 --- /dev/null +++ b/test/reducer.js @@ -0,0 +1,87 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { NotificationReducer } from '../lib/notification-reducer.js'; + +import path from 'path'; +import { fileURLToPath } from 'url'; +import { readFile } from 'node:fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const github = { + notifications: async () => { + const data = await readFile(path.join(__dirname, 'fixtures', 'notifications.json')); + return JSON.parse(data); + }, + batchRequests: async () => { + const pr21 = JSON.parse(await readFile(path.join(__dirname, 'fixtures', 'pr21.json'))); + const pr45 = JSON.parse(await readFile(path.join(__dirname, 'fixtures', 'pr45.json'))); + return [ pr45, pr21 ]; + } +}; + +const notifications = JSON.parse(await readFile(path.join(__dirname, 'fixtures', 'notifications.json'))); +const [ pr21, pr45 ] = [ + JSON.parse(await readFile(path.join(__dirname, 'fixtures', 'pr21.json'))), + JSON.parse(await readFile(path.join(__dirname, 'fixtures', 'pr45.json'))), +]; + +describe('Reducer', async () => { + it('returns pull notifications on #pullNotifications', async () => { + const reducer = new NotificationReducer({ github, notifications }); + + assert.equal(notifications.length, 3); + assert.equal(reducer.pullNotifications.length, 2); + }); + + it('returns notifications + PR info on #notificationsWithPullRequests', async () => { + const reducer = new NotificationReducer({ github, notifications }); + reducer.pullRequests = [ pr21, pr45 ]; + + assert.equal(reducer.notificationsWithPullRequests.length, 2); + assert.deepEqual(Object.keys(reducer.notificationsWithPullRequests[0].pull_request), [ + 'url', + 'html_url', + 'number', + 'state', + 'title', + 'user', + 'body', + 'created_at', + 'updated_at', + 'closed_at', + 'merged_at', + 'merge_commit_sha', + 'requested_reviewers', + 'requested_teams' + ]); + }); + + it('returns closed PRs #notificationsForClosedPRs', async (t) => { + const reducer = new NotificationReducer({ github, notifications }); + reducer.pullRequests = [ pr21, pr45 ]; + + assert.equal(reducer.notificationsForClosedPRs.length, 1); + assert.equal(reducer.notificationsForClosedPRs[0].subject.url, "https://api.github.com/repos/babbel/terraform-aws-acm/pulls/21"); + }); + + it('returns closed PRs #notificationsForReassignedPRs', async (t) => { + const notifications = await github.notifications(); + const reducer = new NotificationReducer({ github, notifications, me: 'awendt' }); + reducer.pullRequests = [ pr21, pr45 ]; + + assert.equal(reducer.notificationsForReassignedPRs.length, 1); + assert.equal(reducer.notificationsForReassignedPRs[0].subject.url, "https://api.github.com/repos/babbel/terraform-aws-athena/pulls/45"); + }); + + it('returns closed PRs #notificationsForReviewedPRs', async (t) => { + const notifications = await github.notifications(); + const reducer = new NotificationReducer({ github, notifications }); + reducer.pullRequests = [ pr21, pr45 ]; + + assert.equal(reducer.notificationsForReviewedPRs.length, 1); + assert.equal(reducer.notificationsForReviewedPRs[0].subject.url, "https://api.github.com/repos/babbel/terraform-aws-acm/pulls/21"); + }); +}); diff --git a/test/unsubscribe.js b/test/unsubscribe.js new file mode 100644 index 0000000..970e88b --- /dev/null +++ b/test/unsubscribe.js @@ -0,0 +1,60 @@ +import assert from 'node:assert'; +import { describe, it, beforeEach, afterEach } from 'node:test'; +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { GitHubClient } from '../lib/client.js'; + +const interceptor = new FetchInterceptor(); +const notifications = [ + { + url: 'https://api.github.com/notifications/threads/123', + subscription_url: 'https://api.github.com/notifications/threads/123/subscription', + }, + { + url: 'https://api.github.com/notifications/threads/124', + subscription_url: 'https://api.github.com/notifications/threads/124/subscription', + }, +]; + +let requests; + +beforeEach(() => { + interceptor.apply(); + + interceptor.on('unhandledException', ({ error }) => console.log(error)); + + requests = []; + + interceptor.on('request', ({ request, controller }) => { + requests.push(`${request.method} ${request.url}`); + + // Always respond with HTTP 204 + controller.respondWith(new Response(null, { status: 204 })); + }); +}); +afterEach(() => interceptor.dispose()); + +describe('#markAsDone', async () => { + it('sends a DELETE request to each thread URL', async () => { + const github = new GitHubClient({}); + + await github.markAsDone(notifications); + assert.deepEqual(requests, [ + "DELETE https://api.github.com/notifications/threads/123", + "DELETE https://api.github.com/notifications/threads/124", + ]); + }); +}); + +describe('#unsubscribe', async () => { + it('sends a DELETE request to each thread URL and subscription URL', async () => { + const github = new GitHubClient({}); + + await github.unsubscribe(notifications); + assert.deepEqual(requests, [ + "DELETE https://api.github.com/notifications/threads/123/subscription", + "DELETE https://api.github.com/notifications/threads/124/subscription", + "DELETE https://api.github.com/notifications/threads/123", + "DELETE https://api.github.com/notifications/threads/124", + ]); + }); +});