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.

  1. // ==UserScript==
  2. // @name Bugzilla - Merge Comments
  3. // @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.
  4. // @namespace RainSlide
  5. // @author RainSlide
  6. // @license AGPL-3.0-or-later
  7. // @version 1.1
  8. // @icon https://www.bugzilla.org/assets/favicon/favicon.ico
  9. // @match https://bugzilla.mozilla.org/show_bug.cgi?*
  10. // @match https://bugzilla.redhat.com/show_bug.cgi?*
  11. // @match https://bugs.kde.org/show_bug.cgi?*
  12. // @grant none
  13. // @inject-into content
  14. // @run-at document-end
  15. // ==/UserScript==
  16.  
  17. "use strict";
  18.  
  19. const $ = (tagName, ...props) => Object.assign(
  20. document.createElement(tagName), ...props
  21. );
  22.  
  23. // "move" an id, from an element, to another element
  24. const moveId = (from, to) => {
  25. const id = from.id;
  26. from.removeAttribute("id");
  27. to.id = id;
  28. };
  29.  
  30. if (location.hostname !== "bugzilla.mozilla.org") {
  31. // Bugzilla 5.0.4; they are easier to deal with
  32.  
  33. let css = `.bz_comment_text > .bz_comment_number,
  34. .bz_comment_text > .bz_comment_time {
  35. float: right;
  36. white-space: normal;
  37. }
  38. .bz_comment_text > .bz_comment_time {
  39. font-family: monospace;
  40. }
  41. .bz_comment_text:not(:hover):not(:target) > .bz_comment_time {
  42. opacity: .5;
  43. }
  44. .bz_comment:target,
  45. .bz_comment_text:target {
  46. outline: 2px solid #006cbf;
  47. }
  48. .bz_comment_text:target {
  49. outline-offset: 2px;
  50. z-index: 1;
  51. }`;
  52.  
  53. if (location.hostname === "bugzilla.redhat.com") css += `
  54. .bz_comment_text:not(:last-child) { border-bottom: 1px solid; }
  55. .bz_comment_text:target { outline-offset: 6px; }`;
  56.  
  57. document.head.append($("style", { textContent: css }));
  58.  
  59. // groups of continuous comments by the same user
  60. const groups = [];
  61.  
  62. let currentUser = null;
  63. document.querySelectorAll(".bz_first_comment ~ .bz_comment").forEach(comment => {
  64.  
  65. // get & check user vcard element
  66. const user = comment.querySelector(":scope .bz_comment_user > .vcard");
  67. if (user === null) {
  68. throw new TypeError('Element ".bz_comment .bz_comment_user > .vcard" not found!');
  69. }
  70.  
  71. // check if is the same user
  72. if (user.textContent !== currentUser) {
  73. // different user, set currentUser, add a new group directly
  74. currentUser = user.textContent;
  75. groups.push([comment]);
  76. } else {
  77. // same user, push to current group
  78. groups.at(-1).push(comment);
  79. }
  80. });
  81.  
  82. const prepareText = comment => {
  83.  
  84. // get & check .bz_comment_text
  85. const text = comment.querySelector(":scope .bz_comment_text");
  86. if (text === null) {
  87. throw new TypeError('Element ".bz_comment .bz_comment_text" not found!');
  88. }
  89.  
  90. // prepend metadata elements (.bz_comment_number, .bz_comment_time)
  91. // into .bz_comment_text if they exist
  92. text.prepend(
  93. ...["number", "time"]
  94. .map(name => comment.querySelector(`:scope .bz_comment_${name}`))
  95. .filter(element => element)
  96. );
  97.  
  98. return text;
  99. };
  100.  
  101. groups.forEach(group => {
  102. if (group.length < 2) return;
  103.  
  104. const first = group[0];
  105. prepareText(first);
  106.  
  107. // starts from 1 to skip the first comment
  108. for (let i = 1; i < group.length; i++) {
  109. const comment = group[i];
  110. const text = prepareText(comment);
  111. moveId(comment, text);
  112. first.append(text);
  113. comment.remove();
  114. }
  115. });
  116.  
  117.  
  118.  
  119. } else {
  120.  
  121. // bugzilla.mozilla.org
  122.  
  123. const css = `.activity .changes-container {
  124. display: flex;
  125. align-items: center;
  126. }
  127. .activity .changes-separator {
  128. display: inline-block;
  129. transform: scaleY(2.5);
  130. white-space: pre;
  131. }
  132. .activity .change-name,
  133. .activity .change-time {
  134. font-size: var(--font-size-medium);
  135. }
  136. .changes-container:target,
  137. .change:target {
  138. outline: 2px solid var(--focused-control-border-color);
  139. }`;
  140.  
  141. document.head.append($("style", { textContent: css }));
  142.  
  143. // Continuous groups of:
  144. // 1. auxiliary .change-set (.change-set with no comment text, id starts with "a")
  145. // 2. by the same author
  146. const aGroups = [];
  147.  
  148. let currentAuthor = null;
  149. let newGroup = true;
  150. document.querySelectorAll("#main-inner > .change-set").forEach(changeSet => {
  151.  
  152. // check if is auxiliary change set
  153. if (changeSet.id[0] !== "a") {
  154. // no, no longer continuous, add a new group for next auxiliary change set
  155. newGroup = true;
  156. return;
  157. }
  158.  
  159. // get & check author vcard element
  160. const author = changeSet.querySelector(":scope .change-author > .vcard");
  161. if (author === null) {
  162. throw new TypeError('Element ".change-set .change-author > .vcard" not found!');
  163. }
  164.  
  165. // check if is the same author
  166. if (author.textContent !== currentAuthor) {
  167. // different author, set currentAuthor, add a new group directly
  168. currentAuthor = author.textContent;
  169. aGroups.push([changeSet]);
  170. newGroup = false;
  171. } else if (!newGroup) {
  172. // same author, push to current group
  173. aGroups.at(-1).push(changeSet);
  174. } else {
  175. // same author, add a new group
  176. aGroups.push([changeSet]);
  177. newGroup = false;
  178. }
  179.  
  180. });
  181.  
  182. // append .change to .activity, create container if needed
  183. const appendChanges = (changeSet, activity, isFirst) => {
  184.  
  185. // get & check .change element(s)
  186. const changes = changeSet.querySelectorAll(":scope > .activity > .change");
  187. if (changes.length === 0) {
  188. throw new TypeError('Element(s) ".change-set > .activity > .change" not found!');
  189. }
  190.  
  191. // get name & time
  192. const tr = changeSet.querySelector(
  193. ':scope > .change > .change-head > tbody > tr[id^="ar-a"]:nth-of-type(2)'
  194. );
  195. const td = tr?.querySelector(":scope > td:only-child");
  196.  
  197. // move name & time into .change or .changes-container, append .changes-container
  198. if (tr && td) {
  199. if (changes.length > 1) {
  200. // a group of .change, create container for nameTime & themselves
  201. const container = $("div", { className: "changes-container" });
  202. const group = $("div", { className: "changes" });
  203. const nameTime = $("div", { id: tr.id });
  204. const separator = $("span", { className: "changes-separator", textContent: "| " });
  205. nameTime.append(...td.childNodes, separator);
  206. group.append(...changes);
  207. container.append(nameTime, group);
  208. tr.remove();
  209.  
  210. // appending .changes-container
  211.  
  212. // "move" an id onto another existing element might mess up the :target highlight,
  213. // so skip that for the first
  214. if (!isFirst) {
  215. moveId(changeSet, container);
  216. }
  217. // but, first .changes-container needs append!
  218. activity.append(container);
  219.  
  220. return;
  221.  
  222. } else {
  223. // only one .change, don't create container, just move nameTime to changes[0]
  224. const nameTime = $("span", { id: tr.id });
  225. nameTime.append(...td.childNodes, "| ");
  226. changes[0].prepend(nameTime);
  227. tr.remove();
  228.  
  229. // no return here, append in if (!isFirst) ... below
  230. }
  231. }
  232.  
  233. // appending .change / a group of .change
  234.  
  235. // first doesn't need move id, see before;
  236. // first .change is already in .activity, doesn't need append either.
  237. if (!isFirst) {
  238. moveId(changeSet, changes[0]);
  239. activity.append(...changes);
  240. }
  241.  
  242. };
  243.  
  244. // merge the .change of each aGroup into the first .change-set with appendChanges()
  245. aGroups.forEach(group => {
  246. if (group.length < 2) return;
  247.  
  248. const first = group[0];
  249. const activity = first.querySelector(":scope > .activity");
  250. appendChanges(first, activity, true);
  251.  
  252. // starts from 1 to skip the first change set
  253. for (let i = 1; i < group.length; i++) {
  254. appendChanges(group[i], activity);
  255. group[i].remove();
  256. }
  257. });
  258.  
  259. }