Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions src/js/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,29 +24,22 @@ const ServiceWorker = () => {
throw error;
}

let recordEntries;
let entries;
try {
recordEntries = await Promise.all([
github.getReviewRequested(),
github.getTeamReviewRequested(),
github.getNoReviewRequested(),
github.getAllReviewsDone(),
github.getMissingAssignee(),
github.getAllAssigned(),
]);
entries = await github.getAll();
} catch (error) {
if (error === tooManyRequestsError) return;

throw error;
}

const record = {
[PullRequestRecordKey.reviewRequested]: recordEntries[0],
[PullRequestRecordKey.teamReviewRequested]: recordEntries[1],
[PullRequestRecordKey.noReviewRequested]: recordEntries[2],
[PullRequestRecordKey.allReviewsDone]: recordEntries[3],
[PullRequestRecordKey.missingAssignee]: recordEntries[4],
[PullRequestRecordKey.allAssigned]: recordEntries[5],
[PullRequestRecordKey.reviewRequested]: entries.reviewRequested,
[PullRequestRecordKey.teamReviewRequested]: entries.teamReviewRequested,
[PullRequestRecordKey.noReviewRequested]: entries.noReviewRequested,
[PullRequestRecordKey.allReviewsDone]: entries.allReviewsDone,
[PullRequestRecordKey.missingAssignee]: entries.missingAssignee,
[PullRequestRecordKey.allAssigned]: entries.allAssigned,
};

await filterIgnoredPrs(record);
Expand Down
205 changes: 134 additions & 71 deletions src/js/services/github-api-wrapper.js
Original file line number Diff line number Diff line change
@@ -1,112 +1,178 @@
import { noAccessTokenError, tooManyRequestsError } from './constants.js';
import SettingsStorageAccessor from './settings-storage-accessor.js';

const GithubApiWrapper = async () => {
const getReviewRequested = async () => {
const query = encodeURIComponent(`is:open is:pr review-requested:${userName} archived:false`);
const pullRequests = await makeApiRequest('/search/issues', `q=${query}`);
const processedPullRequests = await processDataIntoPullRequests(pullRequests.items, false);
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';

// Fields requested for every pull request. `reviewRequests` and `assignees`
// carry the counts that used to require one REST `/requested_reviewers` call
// per pull request, so the whole poll is now a single GraphQL request.
const PULL_REQUEST_FIELDS = `
number
title
url
createdAt
isDraft
author { login }
assignees(first: 1) { totalCount }
reviewRequests(first: 1) { totalCount }
repository { nameWithOwner url }
`;

const teamPullRequestUrls = (await getTeamReviewRequested()).map(pr => pr.url);
return processedPullRequests.filter((pr) => !teamPullRequestUrls.includes(pr.url));
const GithubApiWrapper = async () => {
// Builds one aliased `search` field. GitHub search qualifiers are identical
// to the ones the REST implementation used.
const searchField = (alias, queryString) => `
${alias}: search(query: ${JSON.stringify(queryString)}, type: ISSUE, first: 100) {
nodes { ... on PullRequest { ${PULL_REQUEST_FIELDS} } }
}`;

const buildQuery = (teams) => {
const fields = [
'viewer { login }',
searchField('reviewRequested', `is:open is:pr review-requested:${userName} archived:false`),
searchField('noReviewRequested', `is:open is:pr assignee:${userName} archived:false review:none`),
searchField('allReviewsDone', `is:open is:pr assignee:${userName} archived:false -review:none`),
searchField('missingAssignee', `is:open is:pr author:${userName} draft:false archived:false`),
searchField('allAssigned', `is:open is:pr assignee:${userName} archived:false`),
...teams.map((team, index) => (
searchField(`team${index}`, `is:open is:pr team-review-requested:${team} archived:false`)
)),
];

return `query {${fields.join('\n')}}`;
};

const getTeamReviewRequested = async () => {
const teamNames = async () => {
const teams = await SettingsStorageAccessor().loadTeams();
if (teams === '') return [];
return teams.replace(/ /g, '').split(',').filter((team) => team !== '');
};

let combinedPullRequests = [];

for (const team of teams.replace(/ /g, '').split(',')) {
const query = encodeURIComponent(`is:open is:pr team-review-requested:${team} archived:false`);
const pullRequests = await makeApiRequest('/search/issues', `q=${query}`);
// A single network round-trip that resolves every category at once.
const fetchAll = async () => {
const teams = await teamNames();
const data = await graphqlRequest(buildQuery(teams));

const teamPullRequests = teams.flatMap((_team, index) => nodesOf(data, `team${index}`));

return {
reviewRequested: nodesOf(data, 'reviewRequested'),
teamPullRequests,
noReviewRequested: nodesOf(data, 'noReviewRequested'),
allReviewsDone: nodesOf(data, 'allReviewsDone'),
missingAssignee: nodesOf(data, 'missingAssignee'),
allAssigned: nodesOf(data, 'allAssigned'),
};
};

if (!pullRequests.errors) combinedPullRequests = combinedPullRequests.concat(pullRequests.items);
}
const nodesOf = (data, alias) => (data[alias] && data[alias].nodes) || [];

return processDataIntoPullRequests(combinedPullRequests, false);
};
const getReviewRequested = async () => {
const { reviewRequested, teamPullRequests } = await fetchAll();
const processed = await processNodes(reviewRequested, false);

const getNoReviewRequested = async () => {
return processDataIntoPullRequests(await searchMyIssues('review:none'));
const teamUrls = (await processNodes(teamPullRequests, false)).map((pr) => pr.url);
return processed.filter((pr) => !teamUrls.includes(pr.url));
};

const getAllReviewsDone = async () => {
return processDataIntoPullRequests(await searchMyIssues('-review:none'));
const getTeamReviewRequested = async () => {
const { teamPullRequests } = await fetchAll();
return processNodes(teamPullRequests, false);
};

const searchMyIssues = async (reviewModifier) => {
const query = encodeURIComponent(`is:pr assignee:${userName} archived:false is:open ${reviewModifier}`);
const response = await makeApiRequest('/search/issues', `q=${query}`);

return asyncFilterIssues(response.items, async (PullRequest) => {
const requestedReviewers = (await makeRequest(`${PullRequest.pull_request.url}/requested_reviewers`));
return requestedReviewers.users.length + requestedReviewers.teams.length === 0;
});
// A pull request assigned to me still counts as "no review requested" only
// when nobody (user or team) has been requested for review.
const getNoReviewRequested = async () => {
const { noReviewRequested } = await fetchAll();
return processNodes(noReviewRequested.filter(hasNoReviewRequest));
};

const asyncFilterIssues = async (pullRequests, filter) => {
const response = await Promise.all(pullRequests.map(filter));
return pullRequests.filter((_item, index) => response[index]);
const getAllReviewsDone = async () => {
const { allReviewsDone } = await fetchAll();
return processNodes(allReviewsDone.filter(hasNoReviewRequest));
};

const getMissingAssignee = async () => {
const query = encodeURIComponent(`is:open is:pr author:${userName} draft:false archived:false`);
let response = await makeApiRequest('/search/issues', `q=${query}`);
response = response.items.filter((s) => !s.assignee);

return processDataIntoPullRequests(response);
const { missingAssignee } = await fetchAll();
return processNodes(missingAssignee.filter((node) => node.assignees.totalCount === 0));
};

const getAllAssigned = async () => {
const query = encodeURIComponent(`is:open is:pr assignee:${userName} archived:false`);
const response = await makeApiRequest('/search/issues', `q=${query}`);

return processDataIntoPullRequests(response.items);
const { allAssigned } = await fetchAll();
return processNodes(allAssigned);
};

const makeApiRequest = async (path, params) => makeRequest(`https://api.github.com${path}`, params);
// Resolves all six categories from a single GraphQL round-trip. This is what
// the service worker calls every poll; the individual getters above exist for
// callers that need one category in isolation.
const getAll = async () => {
const raw = await fetchAll();

const teamReviewRequested = await processNodes(raw.teamPullRequests, false);
const teamUrls = teamReviewRequested.map((pr) => pr.url);

return {
reviewRequested: (await processNodes(raw.reviewRequested, false)).filter((pr) => !teamUrls.includes(pr.url)),
teamReviewRequested,
noReviewRequested: await processNodes(raw.noReviewRequested.filter(hasNoReviewRequest)),
allReviewsDone: await processNodes(raw.allReviewsDone.filter(hasNoReviewRequest)),
missingAssignee: await processNodes(raw.missingAssignee.filter((node) => node.assignees.totalCount === 0)),
allAssigned: await processNodes(raw.allAssigned),
};
};

const hasNoReviewRequest = (node) => node.reviewRequests.totalCount === 0;

const makeRequest = async (path, params) => {
const response = await fetch(`${path}?${params}`, {
method: 'GET',
const graphqlRequest = async (query) => {
const response = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa(':' + accessToken),
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
});
if (response.status === 403) throw tooManyRequestsError;
else return response.json();

if (response.status === 403 || response.status === 429) throw tooManyRequestsError;

const body = await response.json();
if (isRateLimited(body)) throw tooManyRequestsError;

return body.data || {};
};

const processDataIntoPullRequests = async (issues, shouldFilterByMaximumAge = true) => {
issues = await filterByScope(issues);
const pullRequests = issues.map(issue => ({
const isRateLimited = (body) => (
Array.isArray(body.errors) && body.errors.some((error) => error.type === 'RATE_LIMITED')
);

const processNodes = async (nodes, shouldFilterByMaximumAge = true) => {
const filtered = await filterByScope(nodes);
const pullRequests = filtered.map((node) => ({
id: 12,
assignee: undefined,
title: issue.title,
number: issue.number,
ownerAndName: readOwnerAndNameFromUrl(issue.pull_request.url),
createdAt: issue.created_at,
ageInDays: getDifferenceInDays(new Date(issue.created_at)),
url: issue.pull_request.url,
repositoryUrl: issue.pull_request.html_url.split('/pull')[0],
htmlUrl: issue.pull_request.html_url,
author: issue.user.login,
title: node.title,
number: node.number,
ownerAndName: node.repository.nameWithOwner,
createdAt: node.createdAt,
ageInDays: getDifferenceInDays(new Date(node.createdAt)),
url: node.url,
repositoryUrl: node.repository.url,
htmlUrl: node.url,
author: node.author ? node.author.login : undefined,
ignored: false,
}));

const sorted = sortByDate(pullRequests);
return shouldFilterByMaximumAge ? filterByMaximumAge(sorted) : sorted;
};

const filterByScope = async (issues) => {
const filterByScope = async (nodes) => {
const scope = await SettingsStorageAccessor().loadScope();
if (scope === '') return issues;
if (scope === '') return nodes;

const individualScopes = (scope).replace(' ', '').toLowerCase().split(',');
return issues.filter(issue => (
individualScopes.includes(readOwnerAndNameFromUrl(issue.pull_request.url).split('/')[0].toLowerCase())
return nodes.filter((node) => (
individualScopes.includes(node.repository.nameWithOwner.split('/')[0].toLowerCase())
));
};

Expand All @@ -115,27 +181,24 @@ const GithubApiWrapper = async () => {
new Date(pullRequest2.createdAt).getTime() - new Date(pullRequest1.createdAt).getTime()
));

const readOwnerAndNameFromUrl = (url) => url.replace('https://api.github.com/repos/', '').split('/pulls/')[0];

const filterByMaximumAge = async (pullRequests) => {
const maximumAge = await SettingsStorageAccessor().loadMaximumAge();

return pullRequests.filter(pullRequest => pullRequest.ageInDays < maximumAge);
return pullRequests.filter((pullRequest) => pullRequest.ageInDays < maximumAge);
};

const getDifferenceInDays = (date2) => (Date.now() - date2.getTime()) / 86_400_000; // 1000 * 3600 * 24

const accessToken = await SettingsStorageAccessor().loadAccessToken();

if (accessToken === '') {
console.error("no access token no party");
console.error('no access token no party');
throw noAccessTokenError;
}

// TODO: This should be cached to improve performance
const userName = (await makeApiRequest('/user')).login;
const userName = (await graphqlRequest('query { viewer { login } }')).viewer.login;

return { getReviewRequested, getTeamReviewRequested, getNoReviewRequested, getAllReviewsDone, getMissingAssignee, getAllAssigned };
return { getAll, getReviewRequested, getTeamReviewRequested, getNoReviewRequested, getAllReviewsDone, getMissingAssignee, getAllAssigned };
};

export default GithubApiWrapper;
29 changes: 16 additions & 13 deletions test/background.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,9 @@ describe('ServiceWorker', () => {
mockedFetch.mockImplementation((url) => globalMock(url, { pullRequestCount: 2 }));
});

describe('while fetching the user', () => {
describe('while resolving the viewer', () => {
beforeAll(() => {
mockedFetch.mockImplementation(() => Promise.resolve({ status: 403 }));
mockedFetch.mockImplementation(() => Promise.resolve({ status: 403, json: () => Promise.resolve({}) }));
});

it('doesn\'t call set', () => {
Expand All @@ -124,12 +124,14 @@ describe('ServiceWorker', () => {

describe('while fetching data', () => {
beforeAll(() => {
mockedFetch.mockImplementation((url) => {
if (url.includes('/user')) {
return Promise.resolve({ json: () => ({ login: 'renuo' }) });
} else {
return Promise.resolve({ status: 403 });
let call = 0;
mockedFetch.mockImplementation(() => {
call += 1;
// First request resolves the viewer login; the batched query is rate-limited.
if (call === 1) {
return Promise.resolve({ status: 200, json: () => Promise.resolve({ data: { viewer: { login: 'renuo' } } }) });
}
return Promise.resolve({ status: 403, json: () => Promise.resolve({}) });
});
});

Expand All @@ -145,7 +147,7 @@ describe('ServiceWorker', () => {
mockedFetch.mockImplementation((url) => globalMock(url, { pullRequestCount: 2 }));
});

describe('while fetching the user', () => {
describe('while resolving the viewer', () => {
beforeAll(() => {
mockedFetch.mockImplementation(() => Promise.resolve({}));
});
Expand All @@ -157,12 +159,13 @@ describe('ServiceWorker', () => {

describe('while fetching data', () => {
beforeAll(() => {
mockedFetch.mockImplementation((url) => {
if (url.includes('/user')) {
return Promise.resolve({ json: () => ({ login: 'renuo' }) });
} else {
return Promise.resolve({});
let call = 0;
mockedFetch.mockImplementation(() => {
call += 1;
if (call === 1) {
return Promise.resolve({ status: 200, json: () => Promise.resolve({ data: { viewer: { login: 'renuo' } } }) });
}
return Promise.resolve({});
});
});

Expand Down
Loading