Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.
Enriches GitHub open pull requests list page with number of files changed, lines added, and lines deleted.
11 changes: 4 additions & 7 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
89 changes: 7 additions & 82 deletions content.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
14 changes: 10 additions & 4 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand All @@ -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"]
}
]
}
8 changes: 5 additions & 3 deletions options.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>GitHub +1 Extension Options</title>
<title>Git +1 Extension Options</title>
</head>
<body>
<label>GitHub Token: <input type="text" id="token" /></label>

<label>GitHub Token: <input type="text" id="github_token" /></label>
<br /><br />
<label>GitLab Token: <input type="text" id="gitlab_token" /></label>
<br /><br />
<button id="save">Save</button>
<div id="status"></div>

Expand Down
22 changes: 15 additions & 7 deletions options.js
Original file line number Diff line number Diff line change
@@ -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)
}

Expand Down
80 changes: 80 additions & 0 deletions sources/github.js
Original file line number Diff line number Diff line change
@@ -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)
}
79 changes: 79 additions & 0 deletions sources/gitlab.js
Original file line number Diff line number Diff line change
@@ -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)
}