Skip to content

Instantly share code, notes, and snippets.

@asimmon
Last active July 8, 2022 15:44
Show Gist options
  • Save asimmon/0fe1fc65ed1f6ff527e6a2471189d6a9 to your computer and use it in GitHub Desktop.
Save asimmon/0fe1fc65ed1f6ff527e6a2471189d6a9 to your computer and use it in GitHub Desktop.
Bitbucket PR comment navigator
// ==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();
@rpdelaney
Copy link

What's it do?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment