Bugzilla - Merge Comments

Merge comments by the same user on several Bugzilla 5.0.4 instance, merge "Updated" / auxiliary changes by the same user on Mozilla Bugzilla.

// ==UserScript==
// @name        Bugzilla - Merge Comments
// @description Merge comments by the same user on several Bugzilla 5.0.4 instance, merge "Updated" / auxiliary changes by the same user on Mozilla Bugzilla.
// @namespace   RainSlide
// @author      RainSlide
// @license     AGPL-3.0-or-later
// @version     1.1
// @icon        https://www.bugzilla.org/assets/favicon/favicon.ico
// @match       https://bugzilla.mozilla.org/show_bug.cgi?*
// @match       https://bugzilla.redhat.com/show_bug.cgi?*
// @match       https://bugs.kde.org/show_bug.cgi?*
// @grant       none
// @inject-into content
// @run-at      document-end
// ==/UserScript==

"use strict";

const $ = (tagName, ...props) => Object.assign(
	document.createElement(tagName), ...props
);

// "move" an id, from an element, to another element
const moveId = (from, to) => {
	const id = from.id;
	from.removeAttribute("id");
	to.id = id;
};

if (location.hostname !== "bugzilla.mozilla.org") {
// Bugzilla 5.0.4; they are easier to deal with

let css = `.bz_comment_text > .bz_comment_number,
.bz_comment_text > .bz_comment_time {
	float: right;
	white-space: normal;
}
.bz_comment_text > .bz_comment_time {
	font-family: monospace;
}
.bz_comment_text:not(:hover):not(:target) > .bz_comment_time {
	opacity: .5;
}
.bz_comment:target,
.bz_comment_text:target {
	outline: 2px solid #006cbf;
}
.bz_comment_text:target {
	outline-offset: 2px;
	z-index: 1;
}`;

if (location.hostname === "bugzilla.redhat.com") css += `
.bz_comment_text:not(:last-child) { border-bottom: 1px solid; }
.bz_comment_text:target { outline-offset: 6px; }`;

document.head.append($("style", { textContent: css }));

// groups of continuous comments by the same user
const groups = [];

let currentUser = null;
document.querySelectorAll(".bz_first_comment ~ .bz_comment").forEach(comment => {

	// get & check user vcard element
	const user = comment.querySelector(":scope .bz_comment_user > .vcard");
	if (user === null) {
		throw new TypeError('Element ".bz_comment .bz_comment_user > .vcard" not found!');
	}

	// check if is the same user
	if (user.textContent !== currentUser) {
		// different user, set currentUser, add a new group directly
		currentUser = user.textContent;
		groups.push([comment]);
	} else {
		// same user, push to current group
		groups.at(-1).push(comment);
	}
});

const prepareText = comment => {

	// get & check .bz_comment_text
	const text   = comment.querySelector(":scope .bz_comment_text");
	if (text === null) {
		throw new TypeError('Element ".bz_comment .bz_comment_text" not found!');
	}

	// prepend metadata elements (.bz_comment_number, .bz_comment_time)
	// into .bz_comment_text if they exist
	text.prepend(
		...["number", "time"]
		.map(name => comment.querySelector(`:scope .bz_comment_${name}`))
		.filter(element => element)
	);

	return text;
};

groups.forEach(group => {
	if (group.length < 2) return;

	const first = group[0];
	prepareText(first);

	// starts from 1 to skip the first comment
	for (let i = 1; i < group.length; i++) {
		const comment = group[i];
		const text = prepareText(comment);
		moveId(comment, text);
		first.append(text);
		comment.remove();
	}
});



} else {

// bugzilla.mozilla.org

const css = `.activity .changes-container {
	display: flex;
	align-items: center;
}
.activity .changes-separator {
	display: inline-block;
	transform: scaleY(2.5);
	white-space: pre;
}
.activity .change-name,
.activity .change-time {
	font-size: var(--font-size-medium);
}
.changes-container:target,
.change:target {
	outline: 2px solid var(--focused-control-border-color);
}`;

document.head.append($("style", { textContent: css }));

// Continuous groups of:
// 1. auxiliary .change-set (.change-set with no comment text, id starts with "a")
// 2. by the same author
const aGroups = [];

let currentAuthor = null;
let newGroup = true;
document.querySelectorAll("#main-inner > .change-set").forEach(changeSet => {

	// check if is auxiliary change set
	if (changeSet.id[0] !== "a") {
		// no, no longer continuous, add a new group for next auxiliary change set
		newGroup = true;
		return;
	}

	// get & check author vcard element
	const author = changeSet.querySelector(":scope .change-author > .vcard");
	if (author === null) {
		throw new TypeError('Element ".change-set .change-author > .vcard" not found!');
	}

	// check if is the same author
	if (author.textContent !== currentAuthor) {
		// different author, set currentAuthor, add a new group directly
		currentAuthor = author.textContent;
		aGroups.push([changeSet]);
		newGroup = false;
	} else if (!newGroup) {
		// same author, push to current group
		aGroups.at(-1).push(changeSet);
	} else {
		// same author, add a new group
		aGroups.push([changeSet]);
		newGroup = false;
	}

});

// append .change to .activity, create container if needed
const appendChanges = (changeSet, activity, isFirst) => {

	// get & check .change element(s)
	const changes = changeSet.querySelectorAll(":scope > .activity > .change");
	if (changes.length === 0) {
		throw new TypeError('Element(s) ".change-set > .activity > .change" not found!');
	}

	// get name & time
	const tr = changeSet.querySelector(
		':scope > .change > .change-head > tbody > tr[id^="ar-a"]:nth-of-type(2)'
	);
	const td = tr?.querySelector(":scope > td:only-child");

	// move name & time into .change or .changes-container, append .changes-container
	if (tr && td) {
		if (changes.length > 1) {
			// a group of .change, create container for nameTime & themselves
			const container = $("div", { className: "changes-container" });
			const group     = $("div", { className: "changes" });
			const nameTime  = $("div", { id: tr.id });
			const separator = $("span", { className: "changes-separator", textContent: "| " });
			nameTime.append(...td.childNodes, separator);
			group.append(...changes);
			container.append(nameTime, group);
			tr.remove();

			// appending .changes-container

			// "move" an id onto another existing element might mess up the :target highlight,
			// so skip that for the first
			if (!isFirst) {
				moveId(changeSet, container);
			}
			// but, first .changes-container needs append!
			activity.append(container);

			return;

		} else {
			// only one .change, don't create container, just move nameTime to changes[0]
			const nameTime = $("span", { id: tr.id });
			nameTime.append(...td.childNodes, "| ");
			changes[0].prepend(nameTime);
			tr.remove();

			// no return here, append in if (!isFirst) ... below
		}
	}

	// appending .change / a group of .change

	// first doesn't need move id, see before;
	// first .change is already in .activity, doesn't need append either.
	if (!isFirst) {
		moveId(changeSet, changes[0]);
		activity.append(...changes);
	}

};

// merge the .change of each aGroup into the first .change-set with appendChanges()
aGroups.forEach(group => {
	if (group.length < 2) return;

	const first = group[0];
	const activity = first.querySelector(":scope > .activity");
	appendChanges(first, activity, true);

	// starts from 1 to skip the first change set
	for (let i = 1; i < group.length; i++) {
		appendChanges(group[i], activity);
		group[i].remove();
	}
});

}