Skip to content

Commit

Permalink
Add rate limit notification component (OctoLinker#541)
Browse files Browse the repository at this point in the history
* Add rate limit notification component

* Update packages/ratelimit-notification/__tests__/__snapshots__/messages.js.snap

Co-Authored-By: stefanbuck <[email protected]>

* Update packages/ratelimit-notification/__tests__/__snapshots__/messages.js.snap

Co-Authored-By: stefanbuck <[email protected]>

* Update packages/ratelimit-notification/messages.js

Co-Authored-By: stefanbuck <[email protected]>

* Update packages/ratelimit-notification/messages.js

Co-Authored-By: stefanbuck <[email protected]>
  • Loading branch information
stefanbuck authored Feb 1, 2019
1 parent f575e2d commit 9ba8c38
Show file tree
Hide file tree
Showing 9 changed files with 327 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`ratelimit-notification needsTokenForPrivate 1`] = `
Object {
"body": "OctoLinker needs a GitHub API token to retrieve repository metadata for private repositories. <a href=\\"#\\" class=\\"js-octolinker-open-settings\\">Create a token</a> to enable OctoLinker for all your private repositories.",
"type": "info",
}
`;

exports[`ratelimit-notification rateLimitExceeded for authenticated requests 1`] = `
Object {
"body": "OctoLinker exceed the GitHub API hourly limit. The rate limit will be reset in 5 seconds.",
"type": "warn",
}
`;

exports[`ratelimit-notification rateLimitExceeded for unauthenticated requests 1`] = `
Object {
"body": "OctoLinker exceed the GitHub API hourly limit for unauthenticated requests. You probably want to <a href=\\"#\\" class=\\"js-octolinker-open-settings\\">create a token</a> or wait 5 minutes.",
"type": "warn",
}
`;

exports[`ratelimit-notification tokenIsInvalid 1`] = `
Object {
"body": "The token you provided is invalid. You must <a href=\\"#\\" class=\\"js-octolinker-open-settings\\">create a new token</a> before you can continue using OctoLinker.",
"type": "error",
}
`;
103 changes: 103 additions & 0 deletions packages/ratelimit-notification/__tests__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
removeAllNotifications,
isPrivateRepository,
} from '@octolinker/user-interface';
import ratelimitNotification from '../index';
import {
tokenIsInvalid,
needsTokenForPrivate,
rateLimitExceeded,
} from '../messages';
import { parse } from '../parse-github-header';

jest.mock('../parse-github-header', () => ({
parse: jest.fn(),
}));

jest.mock('../messages');

jest.mock('@octolinker/user-interface');

parse.mockImplementation(() => ({
rateLimitTotal: 10,
rateLimitRemaining: 20,
rateLimitReset: 30000,
}));

describe('ratelimitNotification', () => {
it('removes all notifications', () => {
ratelimitNotification();
expect(removeAllNotifications).toHaveBeenCalled();
});

it('parse github header', () => {
ratelimitNotification('fakeHeader');
expect(parse).toHaveBeenCalledWith('fakeHeader');
});

describe('when status code is 401', () => {
it('shows invalid token message', () => {
ratelimitNotification('fakeHeader', 401);

expect(tokenIsInvalid).toHaveBeenCalled();
});
});

describe('when status code is 404', () => {
it('shows needs private token message when repository is private', () => {
isPrivateRepository.mockImplementation(() => true);
ratelimitNotification('fakeHeader', 404);

expect(needsTokenForPrivate).toHaveBeenCalled();
});
});

describe('when status code is 403', () => {
describe('when requests is unauthenticated', () => {
it('shows needs private token message when repository is private', () => {
isPrivateRepository.mockImplementation(() => true);
parse.mockImplementation(() => ({
rateLimitTotal: 60,
}));

ratelimitNotification('fakeHeader', 403);

expect(needsTokenForPrivate).toHaveBeenCalled();
});

it('shows rate limit exceeded message', () => {
isPrivateRepository.mockImplementation(() => false);
parse.mockImplementation(() => ({
rateLimitTotal: 60,
rateLimitRemaining: 0,
}));
ratelimitNotification('fakeHeader', 403);

expect(rateLimitExceeded).toHaveBeenCalled();
expect(rateLimitExceeded).toHaveBeenCalledWith({
isUnauthenticated: true,
remainingTime: expect.any(Number),
});
});
});

describe('when requests is authenticated', () => {
it('shows rate limit exceeded message', () => {
isPrivateRepository.mockImplementation(() => false);
parse.mockImplementation(() => ({
rateLimitTotal: 5000,
rateLimitRemaining: 0,
rateLimitReset: 30000,
}));

ratelimitNotification('fakeHeader', 403);

expect(rateLimitExceeded).toHaveBeenCalled();
expect(rateLimitExceeded).toHaveBeenCalledWith({
isUnauthenticated: false,
remainingTime: expect.any(Number),
});
});
});
});
});
43 changes: 43 additions & 0 deletions packages/ratelimit-notification/__tests__/messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { showNotification } from '@octolinker/user-interface';
import {
tokenIsInvalid,
needsTokenForPrivate,
rateLimitExceeded,
} from '../messages';

jest.mock('@octolinker/user-interface');

describe('ratelimit-notification', () => {
it('tokenIsInvalid', () => {
const showNotificationStub = jest.fn();
showNotification.mockImplementation(showNotificationStub);

tokenIsInvalid();
expect(showNotificationStub.mock.calls[0][0]).toMatchSnapshot();
});
it('needsTokenForPrivate', () => {
const showNotificationStub = jest.fn();
showNotification.mockImplementation(showNotificationStub);

needsTokenForPrivate();
expect(showNotificationStub.mock.calls[0][0]).toMatchSnapshot();
});

describe('rateLimitExceeded', () => {
it('for authenticated requests', () => {
const showNotificationStub = jest.fn();
showNotification.mockImplementation(showNotificationStub);

rateLimitExceeded({ isUnauthenticated: false, remainingTime: 5000 });
expect(showNotificationStub.mock.calls[0][0]).toMatchSnapshot();
});

it('for unauthenticated requests', () => {
const showNotificationStub = jest.fn();
showNotification.mockImplementation(showNotificationStub);

rateLimitExceeded({ isUnauthenticated: true, remainingTime: 300000 });
expect(showNotificationStub.mock.calls[0][0]).toMatchSnapshot();
});
});
});
17 changes: 17 additions & 0 deletions packages/ratelimit-notification/__tests__/parse-github-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { parse } from '../parse-github-header';

describe('parse-github-header', () => {
it('parse', () => {
const fakeHeader = new Map([
['x-ratelimit-limit', '10'],
['x-ratelimit-remaining', '20'],
['x-ratelimit-reset', '30'],
]);

expect(parse(fakeHeader)).toEqual({
rateLimitTotal: 10,
rateLimitRemaining: 20,
rateLimitReset: 30000,
});
});
});
47 changes: 47 additions & 0 deletions packages/ratelimit-notification/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
removeAllNotifications,
isPrivateRepository,
} from '@octolinker/user-interface';

import {
tokenIsInvalid,
needsTokenForPrivate,
rateLimitExceeded,
} from './messages';
import { parse } from './parse-github-header';

const MAX_UNAUTHENTICATED_REQUESTS = 60;

document.body.addEventListener('click', event => {
if (event.target.classList.contains('js-octolinker-open-settings')) {
chrome.runtime.sendMessage({ action: 'openSettings' });
}
});

export default (headers, statusCode) => {
const { rateLimitTotal, rateLimitRemaining, rateLimitReset } = parse(headers);

removeAllNotifications();

const isUnauthenticated = rateLimitTotal === MAX_UNAUTHENTICATED_REQUESTS;
const isPrivate = isPrivateRepository();

if (statusCode === 401) {
return tokenIsInvalid();
}

if (isPrivate && statusCode === 404) {
return needsTokenForPrivate();
}

if (statusCode === 403) {
if (isPrivate && isUnauthenticated) {
return needsTokenForPrivate();
}
const remainingTime = rateLimitReset - new Date().getTime();

if (rateLimitRemaining === 0) {
return rateLimitExceeded({ isUnauthenticated, remainingTime });
}
}
};
47 changes: 47 additions & 0 deletions packages/ratelimit-notification/messages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { showNotification } from '@octolinker/user-interface';

const prettyTime = ms => {
const sec = ms / 1000;

if (sec > 120) {
return `${Math.round(sec / 60)} minutes`;
}
if (sec > 60) {
return `${Math.round(sec / 60)} minute`;
}

return `${sec} seconds`;
};

export const rateLimitExceeded = ({ isUnauthenticated, remainingTime }) => {
const timeLeft = prettyTime(remainingTime);

if (isUnauthenticated) {
showNotification({
type: 'warn',
body: `OctoLinker exceed the GitHub API hourly limit for unauthenticated requests. You probably want to <a href="#" class="js-octolinker-open-settings">create a token</a> or wait ${timeLeft}.`,
});
return;
}

showNotification({
type: 'warn',
body: `OctoLinker exceed the GitHub API hourly limit. The rate limit will be reset in ${timeLeft}.`,
});
};

export const needsTokenForPrivate = () => {
showNotification({
type: 'info',
body:
'OctoLinker needs a GitHub API token to retrieve repository metadata for private repositories. <a href="#" class="js-octolinker-open-settings">Create a token</a> to enable OctoLinker for all your private repositories.',
});
};

export const tokenIsInvalid = () => {
showNotification({
type: 'error',
body:
'The token you provided is invalid. You must <a href="#" class="js-octolinker-open-settings">create a new token</a> before you can continue using OctoLinker.',
});
};
11 changes: 11 additions & 0 deletions packages/ratelimit-notification/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@octolinker/ratelimit-notification",
"version": "1.0.0",
"description": "",
"repository": "https://github.com/octolinker/octolinker/tree/master/packages/ratelimit-notification",
"license": "MIT",
"main": "./index.js",
"dependencies": {
"@octolinker/user-interface": "1.0.0"
}
}
12 changes: 12 additions & 0 deletions packages/ratelimit-notification/parse-github-header.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const parse = headers => {
const rateLimitTotal = parseInt(headers.get('x-ratelimit-limit'), 10) || 0;
const rateLimitRemaining =
parseInt(headers.get('x-ratelimit-remaining'), 10) || 0;
const rateLimitReset = (headers.get('x-ratelimit-reset') || 0) * 1000;

return {
rateLimitTotal,
rateLimitRemaining,
rateLimitReset,
};
};
20 changes: 18 additions & 2 deletions packages/user-interface/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
export const showNotification = ({ body }) => {
export const showNotification = ({ body, type = 'info' }) => {
const typeClass = {
info: 'flash-info',
warn: 'flash-warn',
error: 'flash-error',
};

const el = document.createElement('div');
el.innerHTML = `<div class="flash flash-full">
el.innerHTML = `<div class="js-octolinker-flash flash flash-full ${
typeClass[type]
}">
<div class="container">
<button class="flash-close js-flash-close" type="button" aria-label="Dismiss this message">
<svg aria-hidden="true" class="octicon octicon-x" height="16" version="1.1" viewBox="0 0 12 16" width="12"><path d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48z"></path></svg>
Expand All @@ -10,6 +18,12 @@ export const showNotification = ({ body }) => {
</div>`;

document.getElementById('js-flash-container').append(el);

return el;
};

export const removeAllNotifications = () => {
document.querySelectorAll('.js-octolinker-flash').forEach(el => el.remove());
};

export const showTooltip = ($target, msg) => {
Expand All @@ -27,3 +41,5 @@ export const removeTooltip = $target => {

$target.removeAttr('aria-label');
};

export const isPrivateRepository = () => !!document.querySelector('h1.private');

0 comments on commit 9ba8c38

Please sign in to comment.