diff --git a/README.md b/README.md index 394e386..386cb0b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # GitHub +1 Chrome Extension -Enriches GitHub open pull requests list page with number of files changed, lines added, and lines deleted. \ No newline at end of file +Enriches GitHub open pull requests list page with number of files changed, lines added, and lines deleted. diff --git a/background.js b/background.js index bbf1bfa..f794d63 100644 --- a/background.js +++ b/background.js @@ -3,15 +3,12 @@ const REGEX = /(\/[^\/]+\/[^\/]+)?\/pulls/ const NOTIFICATION = { type: 'basic', iconUrl: 'logo.png', - title: `GitHub +1 Failure`, - message: - 'Please verify that the given access token has sufficient access (scope "repo" is required).', + title: `Git +1 Failure`, + message: 'Please verify that the given credentials are correct and have sufficient access.', } -chrome.runtime.onMessage.addListener(({ success }) => - success - ? chrome.notifications.clear(NOTIFICATION_ID) - : chrome.notifications.create(NOTIFICATION_ID, NOTIFICATION) +chrome.runtime.onMessage.addListener(({ success, source }) => + success ? chrome.notifications.clear(source) : chrome.notifications.create(source, NOTIFICATION) ) chrome.tabs.onUpdated.addListener( diff --git a/content.js b/content.js index ea2cbfb..79b1149 100644 --- a/content.js +++ b/content.js @@ -1,87 +1,12 @@ -const REGEX = /^(?:\/([^\/]+)\/([^\/]+))?\/pulls$/ - -function get(token, query) { - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest() - request.onreadystatechange = () => { - if (request.readyState == 4) { - if (request.status != 200) { - reject() - } else { - const body = JSON.parse(request.responseText) - if (body.errors?.length > 0) { - reject() - } else { - resolve(body.data) - } - } - } - } - request.open('POST', 'https://api.github.com/graphql', true) - request.setRequestHeader('Authorization', `Bearer ${token}`) - request.send(`{ "query": "{ ${query.replace(/\s+/g, ' ').replace(/"/g, '\\"')} }" }`) - }) -} - -async function getPullRequestDiffStat(token, org, repo, id) { - const query = `repository(owner: "${org}", name: "${repo}") { - pullRequest(number: ${id}) { - changedFiles - additions - deletions - } - }` - return (await get(token, query)).repository.pullRequest -} - -function appendSpan(div, style, id, value) { - const span = document.createElement('span') - span.className = `${style} ml-1` - span.id = id - span.textContent = value - div.append(span) -} - -function updateSpan(div, id, value) { - div.querySelector(`[id="${id}"]`).textContent = value -} - -function injectHtml(div, diff) { - if (!div.querySelector('[id="stats"]')) { - const element = document.createElement('div') - element.id = 'stats' - appendSpan(element, 'Counter', 'changedFiles', diff.changedFiles) - appendSpan(element, 'color-fg-success', 'additions', '+' + diff.additions) - appendSpan(element, 'color-fg-danger', 'deletions', '-' + diff.deletions) - div.querySelector('[class="opened-by"]').parentNode.append(element) - } else { - updateSpan(div, 'changedFiles', diff.changedFiles) - updateSpan(div, 'additions', '+' + diff.additions) - updateSpan(div, 'deletions', '-' + diff.deletions) - } -} +const DOMAIN_REGEX = /([^\.]+)\.\w+$/ async function run() { - try { - const { token } = await chrome.storage.sync.get('token') - const match = document.location.pathname.match(REGEX) - if (!token || !match) { - return - } - const divs = document.body.querySelectorAll('div[id^=issue_]') - const promises = [] - for (const div of divs) { - const [, id] = div.id.match(/^issue_(\d+)/) - const [, org, repo] = - match[0] === '/pulls' ? div.id.match(/^issue_\d+_([^_]+)_([^_]+)$/) : match - const promise = getPullRequestDiffStat(token, org, repo, id) - promises.concat(promise.then((diff) => injectHtml(div, diff))) - } - await Promise.all(promises) - chrome.runtime.sendMessage({ success: true }) - } catch (error) { - chrome.runtime.sendMessage({ success: false }) - } + const [, source] = document.location.hostname.match(DOMAIN_REGEX) + const { inject } = await import(chrome.runtime.getURL(`sources/${source}.js`)) + const { options } = await chrome.storage.sync.get('options') + await inject(options ?? {}, document.location.pathname) + .then(() => chrome.runtime.sendMessage({ success: true, source })) + .catch(() => chrome.runtime.sendMessage({ success: false, source })) } chrome.runtime.onMessage.addListener(run) diff --git a/manifest.json b/manifest.json index 92c8071..db286bc 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 3, - "name": "GitHub +1", - "description": "Enrich GitHub open pull requests list page with number of files changed, lines added, and lines deleted.", + "name": "Git +1", + "description": "Enrich Git pull/merge requests lists with number of files changed, lines added, and lines deleted.", "version": "1.0", "permissions": ["storage", "tabs", "notifications"], - "host_permissions": ["https://api.github.com/graphql"], + "host_permissions": ["https://api.github.com/graphql", "https://gitlab.com/api/graphql"], "action": { "default_icon": "logo.png" }, @@ -17,8 +17,14 @@ }, "content_scripts": [ { - "matches": ["https://*.github.com/*"], + "matches": ["https://*.github.com/*", "https://*.gitlab.com/*"], "js": ["content.js"] } + ], + "web_accessible_resources": [ + { + "matches": ["https://*/*"], + "resources": ["sources/*.js"] + } ] } diff --git a/options.html b/options.html index 8fde4b8..2cd9549 100644 --- a/options.html +++ b/options.html @@ -1,11 +1,13 @@ - GitHub +1 Extension Options + Git +1 Extension Options - - + +

+ +

diff --git a/options.js b/options.js index a429ed1..26c52fe 100644 --- a/options.js +++ b/options.js @@ -1,15 +1,23 @@ const MESSAGE_TIMEOUT = 2 * 1000 -const restoreOptions = async () => { - const { token } = await chrome.storage.sync.get('token') - document.getElementById('token').value = token +async function restoreOptions() { + const { options } = await chrome.storage.sync.get('options') + document.getElementById('github_token').value = options?.github?.token || '' + document.getElementById('gitlab_token').value = options?.gitlab?.token || '' } -const saveOptions = async () => { - const token = document.getElementById('token').value - await chrome.storage.sync.set({ token }) +async function saveOptions() { + const options = { + github: { + token: document.getElementById('github_token').value, + }, + gitlab: { + token: document.getElementById('gitlab_token').value, + }, + } + await chrome.storage.sync.set({ options }) const status = document.getElementById('status') - status.textContent = 'Token saved successfully' + status.textContent = 'Options saved successfully' setTimeout(() => (status.textContent = ''), MESSAGE_TIMEOUT) } diff --git a/sources/github.js b/sources/github.js new file mode 100644 index 0000000..960d475 --- /dev/null +++ b/sources/github.js @@ -0,0 +1,80 @@ +const REGEX = /^(?:\/([^\/]+)\/([^\/]+))?\/pulls$/ + +function get(token, query) { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.onreadystatechange = () => { + if (request.readyState == 4) { + if (request.status != 200) { + reject() + } else { + const body = JSON.parse(request.responseText) + if (body.errors?.length > 0) { + reject() + } else { + resolve(body.data) + } + } + } + } + request.open('POST', 'https://api.github.com/graphql', true) + request.setRequestHeader('Authorization', `Bearer ${token}`) + request.send(`{ "query": "{ ${query.replace(/\s+/g, ' ').replace(/"/g, '\\"')} }" }`) + }) +} + +async function getDiffStats(token, org, repo, id) { + const query = `repository(owner: "${org}", name: "${repo}") { + pullRequest(number: ${id}) { + changedFiles + additions + deletions + } + }` + return (await get(token, query)).repository.pullRequest +} + +function appendSpan(item, style, id, value) { + const span = document.createElement('span') + span.className = `${style} ml-1` + span.id = id + span.textContent = value + item.append(span) +} + +function updateSpan(item, id, value) { + item.querySelector(`[id="${id}"]`).textContent = value +} + +function injectHtml(item, stats) { + if (!item.querySelector('[id="stats"]')) { + const element = document.createElement('span') + element.id = 'stats' + appendSpan(element, 'Counter', 'files', stats.changedFiles) + appendSpan(element, 'color-fg-success', 'additions', '+' + stats.additions) + appendSpan(element, 'color-fg-danger', 'deletions', '-' + stats.deletions) + item.querySelector('[class="opened-by"]').parentNode.append(element) + } else { + updateSpan(item, 'files', stats.changedFiles) + updateSpan(item, 'additions', '+' + stats.additions) + updateSpan(item, 'deletions', '-' + stats.deletions) + } +} + +export async function inject(options, path) { + const { token } = options.github ?? {} + const match = path.match(REGEX) + if (!token || !match) { + return + } + const items = document.body.querySelectorAll('div[id^=issue_]') + const promises = [] + for (const item of items) { + const [, id] = item.id.match(/^issue_(\d+)/) + const [, org, repo] = + match[0] === '/pulls' ? item.id.match(/^issue_\d+_([^_]+)_([^_]+)$/) : match + const promise = getDiffStats(token, org, repo, id) + promises.concat(promise.then((diff) => injectHtml(item, diff))) + } + await Promise.all(promises) +} diff --git a/sources/gitlab.js b/sources/gitlab.js new file mode 100644 index 0000000..366d681 --- /dev/null +++ b/sources/gitlab.js @@ -0,0 +1,79 @@ +const REGEX = /^(\/[^\/]+\/[^\/]+\/-\/merge_requests)|(\/dashboard\/merge_requests)$/ + +function get(token, query) { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.onreadystatechange = () => { + if (request.readyState == 4) { + if (request.status != 200) { + reject() + } else { + const body = JSON.parse(request.responseText) + if (body.errors?.length > 0) { + reject() + } else { + resolve(body.data) + } + } + } + } + request.open('POST', 'https://gitlab.com/api/graphql', true) + request.setRequestHeader('Authorization', `Bearer ${token}`) + request.setRequestHeader('Content-Type', 'application/json') + request.send(`{ "query": "{ ${query.replace(/\s+/g, ' ').replace(/"/g, '\\"')} }" }`) + }) +} + +async function getDiffStats(token, id) { + const query = `mergeRequest(id: "gid://gitlab/MergeRequest/${id}") { + diffStatsSummary { + fileCount + additions + deletions + } + }` + return (await get(token, query)).mergeRequest.diffStatsSummary +} + +function appendSpan(item, style, id, value) { + const span = document.createElement('span') + span.className = `${style} ml-1` + span.id = id + span.textContent = value + item.append(span) +} + +function updateSpan(item, id, value) { + item.querySelector(`[id="${id}"]`).textContent = value +} + +function injectHtml(item, stats) { + if (!item.querySelector('[id="stats"]')) { + const element = document.createElement('span') + element.id = 'stats' + appendSpan(element, 'gl-badge badge badge-pill badge-muted sm', 'files', stats.fileCount) + appendSpan(element, 'gl-text-green-600 bold', 'additions', '+' + stats.additions) + appendSpan(element, 'gl-text-red-500 bold', 'deletions', '-' + stats.deletions) + item.querySelector('[class*="issuable-authored"]').append(element) + } else { + updateSpan(item, 'files', stats.fileCount) + updateSpan(item, 'additions', '+' + stats.additions) + updateSpan(item, 'deletions', '-' + stats.deletions) + } +} + +export async function inject(options, path) { + const { token } = options?.gitlab ?? {} + const match = REGEX.test(path) + if (!token || !match) { + return + } + const items = document.body.querySelectorAll('li[id^=merge_request_]') + const promises = [] + for (const item of items) { + const id = item.getAttribute('data-id') + const promise = getDiffStats(token, id) + promises.concat(promise.then((stats) => injectHtml(item, stats))) + } + await Promise.all(promises) +}