Last active
July 8, 2022 15:44
-
-
Save asimmon/0fe1fc65ed1f6ff527e6a2471189d6a9 to your computer and use it in GitHub Desktop.
Bitbucket PR comment navigator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Better Bitbucket Pull Requests | |
// @namespace GSoft | |
// @author Anthony Simmon | |
// @version 1.0.0 | |
// @match https://bitbucket.org/*/pull-requests/* | |
// @match https://bitbucket.org/*/branch/* | |
// ==/UserScript== | |
function BBPR_App () { | |
const BBPR_PREV_ID = "bbpr-prev"; | |
const BBPR_NEXT_ID = "bbpr-next"; | |
const BBPR_CURR_ID = "bbpr-curr"; | |
const BBPR_COUNT_ID = "bbpr-count"; | |
const BBPR_RELOAD_ID = "bbpr-reload"; | |
const BBPR_SKIP_RESOLVED_ID = "bbpr-skip-resolved"; | |
const BBPR_BUILD_STATUS_URL_ID = "bbpr-build-status-url"; | |
const BBPR_BUILD_STATUS_ICON_ID = "bbpr-build-status-icon"; | |
const BBPR_MARK_AS_READ_CHECKBOX_CLASSNAME = "bbpr-mar-chk"; | |
const RESOLVED_STRS = [ | |
"RESOLVED", "FIXED", "CLOSED", "DONE" | |
]; | |
const COMMENT_NAVIGATOR_HTML = ` | |
<a id="${BBPR_BUILD_STATUS_URL_ID}" href="javascript:void(0)" target="_blank"><img id="${BBPR_BUILD_STATUS_ICON_ID}" src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" alt="Build status" style="vertical-align: text-top" /></a> | |
- <a id="${BBPR_PREV_ID}" href="#">prev</a> | |
(<span id="${BBPR_CURR_ID}">?</span> / <span id="${BBPR_COUNT_ID}">?</span>) | |
<a id="${BBPR_NEXT_ID}" href="#">next</a> | |
(<a id="${BBPR_RELOAD_ID}" href="#">reload</a>) | |
- skip resolved | |
<input id="${BBPR_SKIP_RESOLVED_ID}" type="checkbox" title="Look for occurence of ${RESOLVED_STRS.join(', ')} in each comments chain" />`; | |
const MARK_AS_READ_HTML = ` | |
<span style="border: 1px solid #dfe1ef; background-color: #f4f5f7; padding: 4px 8px; border-radius: 5px;"> | |
Mark as read | |
<input type="checkbox" class="${BBPR_MARK_AS_READ_CHECKBOX_CLASSNAME}" /> | |
</span>`; | |
this.pullRequestName = ""; | |
this.comments = []; | |
this.files = []; | |
this.currCommentIdx = NaN; | |
this.loadState = () => { | |
let defaultState = { | |
files: [] | |
}; | |
let stateStr = window.localStorage.getItem("bbpr-state"); | |
if (!stateStr) | |
return defaultState; | |
return JSON.parse(stateStr); | |
}; | |
this.saveState = () => { | |
let files = this.files.map(f => ({ | |
uid: f.uid, | |
read: f.read, | |
linesAdded: f.linesAdded, | |
linesRemoved: f.linesRemoved | |
})); | |
let state = this.loadState(); | |
state.files = state.files.filter((oldFile) => { | |
return files.findIndex(newFile => newFile.uid === oldFile.uid) === -1; | |
}); | |
state.files = state.files.concat(files); | |
let stateStr = JSON.stringify(state); | |
window.localStorage.setItem("bbpr-state", stateStr); | |
}; | |
this.getPullRequestUrlWithoutAnchor = () => { | |
let url = window.location.href; | |
let idx = url.indexOf('#'); | |
return idx >= 0 ? url.substring(0, idx) : url; | |
}; | |
this.isCommentResolved = (text) => { | |
var lines = text.split("\n"); | |
lines = lines.map(function(line) { | |
return line.trim().toUpperCase().normalize('NFD').replace(/[^A-Z]+/g, ""); | |
}); | |
lines = lines.filter(function(line) { | |
return line.length > 0; | |
}); | |
if (lines.length === 0) | |
return false; | |
return RESOLVED_STRS.indexOf(lines[lines.length - 1]) >= 0; | |
}; | |
this.extractComment = (el, absUrl) => { | |
let isResolved = this.isCommentResolved(el.querySelector(".comment-content").innerText); | |
return { | |
url: absUrl + "#" + el.id, | |
el: el, | |
children: [], | |
resolved: isResolved | |
}; | |
}; | |
this.sortCommentHierarchically = (comments) => { | |
if (comments.length <= 1) | |
return comments; | |
let commentEls = comments.map(c => c.el.parentNode); | |
for (let i = commentEls.length - 1; i >= 1; i--) { | |
for (let j = i - 1; j >= 0; j--) { | |
if (commentEls[i].parentNode.parentNode === commentEls[j]) { | |
comments[j].children.unshift(comments[i]); | |
if (comments[i].resolved) | |
comments[j].resolved = true; | |
comments.splice(i, 1); | |
} | |
} | |
} | |
return comments; | |
}; | |
this.getComments = () => { | |
let url = this.getPullRequestUrlWithoutAnchor(); | |
let commentEls = document.querySelectorAll("article[id^='comment-']"); | |
let comments = [...commentEls].map(el => this.extractComment(el, url)); | |
comments = this.sortCommentHierarchically(comments); | |
let skipResolved = document.getElementById(BBPR_SKIP_RESOLVED_ID).checked; | |
if (skipResolved) | |
comments = comments.filter(c => !c.resolved); | |
return comments; | |
}; | |
this.makeFile = (el) => { | |
const id = el.querySelector("a.execute").href.split("#")[1]; | |
let file = { | |
filename: el.querySelector("a.execute").innerText, | |
linkEl: el, | |
diffEl: document.getElementById(id), | |
uid: this.pullRequestName + "-" + id, | |
id: id, | |
linesAdded: parseInt(el.querySelector("span.lines-added").innerText.replace("+", "")), | |
linesRemoved: parseInt(el.querySelector("span.lines-removed").innerText.replace("-", "")), | |
read: false | |
}; | |
if (isNaN(file.linesAdded)) | |
file.linesAdded = 0; | |
if (isNaN(file.linesRemoved)) | |
file.linesRemoved = 0; | |
return file; | |
}; | |
this.getFiles = () => { | |
let fileEls = document.querySelectorAll("#commit-files-summary li"); | |
let files = [...fileEls].map(this.makeFile); | |
let state = this.loadState(); | |
state.files.forEach(function (state) { | |
let file = files.find(f => f.uid === state.uid); | |
if (file) { | |
let hasNotChanged = file.linesAdded === state.linesAdded && file.linesRemoved === state.linesRemoved; | |
file.read = state.read && hasNotChanged; | |
} | |
}); | |
return files; | |
}; | |
this.refreshCommentUI = () => { | |
let currId = this.getScaledCommentIdx(); | |
document.getElementById(BBPR_CURR_ID).innerHTML = isNaN(currId) ? "?" : (currId + 1).toString(); | |
document.getElementById(BBPR_COUNT_ID).innerHTML = this.comments.length.toString(); | |
}; | |
this.makeMarkAsReadUI = (file) => { | |
let el = file.diffEl.querySelector(".bbpr-mar"); | |
if (el) | |
el.remove(); | |
el = document.createElement("div"); | |
el.className = "bbpr-mar"; | |
el.setAttribute("style", "padding-top: 10px; text-align: right;"); | |
el.innerHTML = MARK_AS_READ_HTML; | |
el.querySelector("input").checked = file.read; | |
file.diffEl.appendChild(el); | |
}; | |
this.refreshFileUI = (file) => { | |
if (file.linkEl) | |
file.linkEl.style.fontWeight = file.read ? "normal" : "bold"; | |
if (file.diffEl) { | |
file.diffEl.style.opacity = file.read ? "0.5" : "1.0"; | |
this.makeMarkAsReadUI(file); | |
} | |
}; | |
this.setBuildStatus = () => { | |
const prBranchNameSelector = "#id_source_group a"; | |
const brBranchNameSelector = "#branch-detail div.branch.source span.branch-name"; | |
let branchNameEl = document.querySelector(prBranchNameSelector) || document.querySelector(brBranchNameSelector); | |
let branchName = branchNameEl.innerText.trim(); | |
let base64BranchName = btoa(branchName); | |
let urlEncodedBranchName = encodeURIComponent(branchName); | |
let buildUrl = `https://teamcity.share-gate.com/viewType.html?buildTypeId=Sharegate_Management&branch_Sharegate=${urlEncodedBranchName}`; | |
let iconUrl = `https://teamcity.share-gate.com/app/rest/builds/buildType:(id:Sharegate_Management),branch:($base64:${base64BranchName}),running:any/statusIcon.svg`; | |
document.getElementById(BBPR_BUILD_STATUS_URL_ID).href = buildUrl; | |
document.getElementById(BBPR_BUILD_STATUS_ICON_ID).src = iconUrl; | |
}; | |
this.refreshUI = () => { | |
this.setBuildStatus(); | |
this.refreshCommentUI(); | |
this.files.forEach(this.refreshFileUI); | |
}; | |
this.getScaledCommentIdx = () => { | |
if (this.comments.length === 0) | |
return NaN; | |
let scaledIdx = this.currCommentIdx % this.comments.length; | |
return scaledIdx >= 0 ? scaledIdx : Math.abs(this.comments.length + scaledIdx); | |
}; | |
this.trySetCurrentCommentIdxBasedOnCurrentUrl = () => { | |
let idx = this.comments.map(c => c.url).indexOf(window.location.href); | |
if (idx >= 0) | |
this.currCommentIdx = idx; | |
}; | |
this.getPullRequestName = () => { | |
const prSelector = "#pull-request-header a.pull-request-self-link"; | |
const branchSelector = "#branch-detail div.branch.source span.branch-name"; | |
let branchNameEl = document.querySelector(prSelector) || document.querySelector(branchSelector); | |
return branchNameEl.innerText.replace("#", "").replace("/", "-"); | |
}; | |
this.reload = () => { | |
this.pullRequestName = this.getPullRequestName(); | |
this.comments.length = 0; | |
this.comments.push(...this.getComments()); | |
this.files.length = 0; | |
this.files.push(...this.getFiles()); | |
this.trySetCurrentCommentIdxBasedOnCurrentUrl(); | |
this.refreshUI(); | |
}; | |
this.iterateComment = (delta) => { | |
if (this.comments.length === 0) | |
return; | |
if (isNaN(this.currCommentIdx)) | |
this.currCommentIdx = (delta > 0) ? -1 : 0; | |
this.currCommentIdx += delta; | |
window.location.replace(this.comments[this.getScaledCommentIdx()].url); | |
this.refreshUI(); | |
}; | |
this.goToPrevComment = () => this.iterateComment(-1); | |
this.goToNextComment = () => this.iterateComment(1); | |
this.clickHandlers = { | |
[BBPR_RELOAD_ID]: this.reload, | |
[BBPR_SKIP_RESOLVED_ID]: this.reload, | |
[BBPR_PREV_ID]: this.goToPrevComment, | |
[BBPR_NEXT_ID]: this.goToNextComment, | |
}; | |
this.handleClickOnElementWithId = (event, el) => { | |
if (el.tagName === "A") | |
event.preventDefault(); | |
this.clickHandlers[el.id](); | |
}; | |
this.handleClickOnMarkAsReadCheckbox = (event, el) => { | |
let file = this.files.find(f => f.id === el.closest("section").id); | |
if (file) { | |
file.read = el.checked; | |
this.refreshFileUI(file); | |
this.saveState(); | |
} | |
}; | |
this.handleClick = (event) => { | |
let el = event.target || event.srcElement; | |
if (el.id && this.clickHandlers.hasOwnProperty(el.id)) { | |
this.handleClickOnElementWithId(event, el); | |
} | |
else if (el.className === BBPR_MARK_AS_READ_CHECKBOX_CLASSNAME) { | |
this.handleClickOnMarkAsReadCheckbox(event, el); | |
} else if ((el.href && el.href.endsWith("#compare-diff")) || el.id === 'pr-menu-diff' || el.id === 'pr-menu-activity') { | |
this.reload(); | |
setTimeout(() => this.reloadWhenPageLoaded(), 500); | |
} | |
}; | |
this.hookUI = () => { | |
document.addEventListener("click", this.handleClick, false); | |
}; | |
this.makeUI = () => { | |
let root = document.createElement("div"); | |
root.innerHTML = COMMENT_NAVIGATOR_HTML; | |
root.setAttribute("style", "position: fixed; background: #f4f5f7; border: 1px solid #dfe1ef; border-radius: 5px; z-index: 99999; right: 5px; bottom: 5px; padding: 3px 8px;"); | |
document.body.appendChild(root); | |
}; | |
this.onNodeInserted = (mutations, observer) => { | |
for (let mutation of mutations) { | |
if (mutation.type === 'childList' && mutation.addedNodes.length) { | |
for (let node of mutation.addedNodes) { | |
if (node.id === "pullrequest-diff" || node.id === "commit-files-summary") { | |
setTimeout(() => observer.disconnect(), 10); | |
setTimeout(this.reload, 500); | |
return; | |
} | |
} | |
} | |
} | |
}; | |
this.reloadWhenPageLoaded = () => { | |
let diffContainer = document.getElementById('pr-tab-content') || document.getElementById('compare-diff-content'); | |
if (diffContainer) { | |
let config = {childList: true}; | |
let observer = new MutationObserver(this.onNodeInserted); | |
observer.observe(diffContainer, config); | |
setTimeout(() => observer.disconnect(), 10000); | |
} | |
}; | |
this.run = () => { | |
this.makeUI(); | |
this.hookUI(); | |
this.reloadWhenPageLoaded(); | |
}; | |
this.run(); | |
} | |
let instance = new BBPR_App(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
What's it do?