app/buglist.mjs (468 lines of code) (raw):

import { _, __, chunked, cloneTemplate, shuffle, timeAgo, updateTemplate } from "util"; import * as Bugzilla from "bugzilla"; import * as Dialog from "dialog"; import * as Global from "global"; import * as Menu from "menus"; import * as Tooltips from "tooltips"; /* global tippy */ const g = { buglists: {}, }; export function initUI() { document.addEventListener("click", async (event) => { // check for clicks within a buglist header if (event.target.closest(".buglist-header")) { const $buglist = event.target.closest(".buglist-container"); if (!$buglist) return; // refresh button const $refreshBtn = event.target.closest(".refresh-btn"); if ($refreshBtn) { if ( $buglist.classList.contains("lazy") && $buglist.classList.contains("closed") ) { await Dialog.alert( "This list is expensive, and must be expanded before bugs can be loaded.", ); } else { refresh($buglist.id); } return; } // open-in-bugzilla button const $buglistBtn = event.target.closest(".buglist-btn"); if ($buglistBtn) { window.open(Bugzilla.buglistUrl($buglistBtn.bugIDs), "_blank"); return; } // toggle open/closed if ( !$buglist.classList.contains("no-bugs") || $buglist.classList.contains("lazy") ) { $buglist.classList.toggle("closed"); if ( !$buglist.classList.contains("closed") && $buglist.classList.contains("lazy") && $buglist.classList.contains("loading") ) { _($buglist, ".buglist-header .counter").textContent = "-"; refresh($buglist.id); } } return; } // buglist group actions if (event.target.closest(".buglist-group-actions")) { const $target = event.target; if ($target.nodeName !== "A") return; const collapse = $target.dataset.action === "collapse"; for (const $container of $target .closest(".buglist-group") .querySelectorAll(".buglist-container")) { if ($container.classList.contains("no-bugs")) continue; if (collapse) { $container.classList.add("closed"); } else { $container.classList.remove("closed"); } } event.preventDefault(); } }); // listen for global refresh event document.addEventListener("refresh", () => { const componentsSelected = Global.selectedComponents().length > 0; for (const id of Object.keys(g.buglists)) { if (g.buglists[id].usesComponents && !componentsSelected) { continue; } if (g.buglists[id].initialised) { refresh(id); } } }); } export function initUiLast() { for (const $button of __(".order-btn")) { const buglist = g.buglists[$button.closest(".buglist-container").id]; const $menuAction = $button.closest(".action"); Menu.initOptionsMenu( $menuAction, _("#order-menu-template"), () => { return buglist.order; }, (value, text) => { Tooltips.set($menuAction, value === "default" ? "" : text); $button.dataset.mode = value; buglist.order = value; refresh(buglist.id); }, ); } } export function newGroup($container) { const $root = cloneTemplate(_("#buglist-group-template")).querySelector( ".buglist-group", ); $container.append($root); return $root; } export function append({ id, $container, title, description, query, include, template, augment, order, usesComponents, lazyLoad, limit, augmentRow, } = {}) { const $root = cloneTemplate(_("#buglist-template")).querySelector( ".buglist-container", ); $root.id = id; if (lazyLoad) { description = `${description.trim()}\n\nThis list is expensive to generate and will only load when expanded.`; } updateTemplate($root, { title: title, description: description }); $container.append($root); g.buglists[id] = { id: id, $root: $root, query: query, includeFn: include, $timestampTemplate: _(`#bug-row-timestamp-${template || "creation"}`), augmentFn: augment, order: "default", orderFn: order, usesComponents: usesComponents, lazyLoad: lazyLoad, limit: limit, url: undefined, initialised: false, augmentRow: augmentRow, }; if (lazyLoad) { $root.classList.add("lazy"); $root.classList.add("lazy-unloaded"); } } export function updateQuery(id) { const buglist = g.buglists[id]; const url = Bugzilla.queryURL( buglist.query, buglist.usesComponents ? Global.selectedComponents() : undefined, ); if (url !== buglist.url) { buglist.url = url; refresh(id); } } const typeMaterialIconNames = { defect: "brightness_7", enhancement: "add_box", task: "assignment", private: "lock", }; const severityTitles = { S1: "Catastrophic", S2: "Serious", S3: "Normal", S4: "Trivial", "n/a": "Not Applicable", normal: "Retriage", }; function setErrorState(buglist) { buglist.$root.classList.remove("loading"); buglist.$root.classList.add("closed"); buglist.$root.classList.add("no-bugs"); buglist.$root.classList.add("error"); if (buglist.$root.classList.contains("lazy")) { buglist.$root.classList.add("lazy-unloaded"); buglist.$root.classList.add("loading"); } _(buglist.$root, ".buglist-header .counter").textContent = "Failed to load bugs"; } export async function refresh(id) { const buglist = g.buglists[id]; for (const $button of __(buglist.$root, "button")) { if (!$button.classList.contains("refresh-btn")) { $button.disabled = true; } } if (buglist.lazyLoad) { if (buglist.$root.classList.contains("closed")) { // don't load bugs in lazy-and-collapsed lists return; } buglist.$root.classList.remove("lazy-unloaded"); } const $list = _(buglist.$root, ".buglist"); buglist.$root.classList.add("loading"); buglist.$root.classList.remove("no-bugs"); buglist.$root.classList.remove("error"); buglist.initialised = true; $list.innerHTML = ""; // execute query let response; try { response = await Bugzilla.rest(buglist.url); } catch (error) { setErrorState(buglist); return; } // exit early if there are too many bugs to avoid hitting BMO rate limits // we do this before applying filters as some filters request more data from BMO const limit = buglist.limit || 2000; if (response.bugs.length >= limit) { buglist.$root.classList.remove("loading"); buglist.$root.classList.add("no-bugs"); buglist.$root.classList.add("error"); _(buglist.$root, ".buglist-header .counter").textContent = "Too many bugs (" + response.bugs.length + ")"; return; } // build results const now = Date.now(); let bugs = []; for (const bug of response.bugs) { bug.url = `https://bugzilla.mozilla.org/show_bug.cgi?id=${bug.id}`; bug.severity_title = severityTitles[bug.severity] || ""; bug.creation_epoch = Date.parse(bug.creation_time); bug.creation_ago = timeAgo(bug.creation_epoch); bug.creation = new Date(bug.creation_epoch).toLocaleString(); bug.updated_epoch = Date.parse(bug.last_change_time); bug.updated_ago = timeAgo(bug.updated_epoch); bug.updated = new Date(bug.updated_epoch).toLocaleString(); bug.type_icon = typeMaterialIconNames[bug.type]; if (bug.groups.length > 0) { bug.groups = bug.groups.join(","); bug.groups_icon = typeMaterialIconNames.private; } bug.owner = bug.assigned_to === "nobody@mozilla.org" ? "-" : bug.assigned_to_detail.nick || bug.assigned_to_detail.real_name; if ( bug.assigned_to !== "nobody@mozilla.org" && bug.owner !== bug.assigned_to_detail.real_name ) { bug.owner_name = bug.assigned_to_detail.real_name; } bug.reporter = bug.creator_detail.nick || bug.creator_detail.real_name; if (bug.reporter !== bug.creator_detail.real_name) { bug.reporter_name = bug.creator_detail.real_name; } bug.severity = bug.severity === "--" ? "-" : bug.severity; bug.priority = bug.priority === "--" ? "-" : bug.priority; if (bug.flags !== undefined) { const needinfos = []; for (const flag of bug.flags) { if (flag.name === "needinfo") { flag.epoch = Date.parse(flag.creation_date); flag.date = new Date(flag.epoch).toLocaleString(); flag.age = Math.ceil((now - flag.epoch) / (1000 * 3600 * 24)); flag.ago = timeAgo(flag.epoch); needinfos.push(flag); } } bug.needinfos = needinfos.sort((a, b) => b.age - a.age); } if (bug.keywords) { bug.keywords = bug.keywords.join(" "); } bugs.push(bug); } // apply filters if (buglist.includeFn !== undefined) { if (buglist.includeFn.constructor.name === "AsyncFunction") { // async function (eg. queries Bugzilla) // run in parallel, but no more than 10 at a time let failed = false; const chunkedBugs = chunked(bugs, 10); for (const bugChunk of chunkedBugs) { const includePromises = []; for (const bug of bugChunk) { includePromises.push( // biome-ignore lint/suspicious/noAsyncPromiseExecutor: new Promise(async (resolve) => { try { bug.include = await buglist.includeFn(bug); } catch (error) { failed = true; } resolve(true); }), ); } await Promise.allSettled(includePromises); } if (failed) { setErrorState(buglist); return; } } else { for (const bug of bugs) { bug.include = buglist.includeFn(bug); } } bugs = bugs.filter((bug) => bug.include); } _(buglist.$root, ".buglist-header .counter").textContent = `${bugs.length} bug${ bugs.length === 1 ? "" : "s" }`; // get details of needinfo requestees const usernamesSet = new Set(); for (const bug of bugs) { for (const ni of bug.needinfos) { usernamesSet.add(ni.requestee); } } const usernames = Array.from(usernamesSet); if (usernames.length > 0) { const users = {}; if (Global.getAccount()) { // auth is required to get full user details const chunkedUsernames = chunked(usernames, 100); for (const usernamesChunk of chunkedUsernames) { const args = ["include_fields=email,nick,real_name"]; for (const username of usernamesChunk) { args.push(`names=${encodeURIComponent(username)}`); } const res = await Bugzilla.rest("user", args.join("&")); for (const user of res.users) { users[user.email] = user; } } } else { for (const username of usernames) { users[username] = { email: username, nick: username.split("@")[0], // eslint-disable-next-line camelcase real_name: "", }; } } for (const bug of bugs) { for (const ni of bug.needinfos) { ni.requestee_detail = users[ni.requestee]; } } } // augment and sort bug lists for (const bug of bugs) { bug.assigned_to_nick = bug.assigned_to === "nobody@mozilla.org" ? "-" : bug.assigned_to_detail.nick || bug.assigned_to_detail.real_name; bug.assigned_to_name = bug.assigned_to === "nobody@mozilla.org" || bug.assigned_to_nick === bug.assigned_to_detail.real_name ? "" : bug.assigned_to_detail.real_name; bug.creator_nick = bug.creator_detail.nick || bug.creator_detail.real_name; bug.creator_name = bug.creator_nick === bug.creator_detail.real_name ? "" : bug.creator_detail.real_name; // eslint-disable-next-line camelcase bug.needinfo_icon = " "; if (bug.needinfos.length > 0) { for (const ni of bug.needinfos) { ni.requestee_nick = ni.requestee_detail.nick || ni.requestee_detail.real_name; ni.requestee_name = ni.requestee_nick === ni.requestee_detail.real_name ? "" : ni.requestee_detail.real_name; } // eslint-disable-next-line camelcase bug.needinfo_icon = "live_help"; // eslint-disable-next-line camelcase bug.needinfo_target = `NEEDINFO: ${bug.needinfos[0].requestee_nick} ` + `(${bug.needinfos[0].ago})`; } } if (buglist.augmentFn !== undefined) { for (const bug of bugs) { buglist.augmentFn(bug); } } for (const bug of bugs) { if (!bug.timestamp) { bug.timestamp = bug.creation; bug.timestamp_ago = bug.creation_ago; } } // sort switch (buglist.order) { case "oldest": { bugs.sort((a, b) => a.creation_epoch - b.creation_epoch); break; } case "newest": { bugs.sort((a, b) => b.creation_epoch - a.creation_epoch); break; } case "random": { bugs = shuffle(bugs); break; } default: { if (buglist.orderFn) { bugs.sort(buglist.orderFn); } else { bugs.sort((a, b) => a.creation_epoch - b.creation_epoch); } break; } } // update dom for (const $button of __(buglist.$root, "button")) { $button.disabled = false; } if (bugs.length > 0) { _(buglist.$root, ".buglist-header .buglist-btn").bugIDs = bugs.map( (bug) => bug.id, ); _(buglist.$root, ".buglist-header .buglist-btn").dataset.url = Bugzilla.buglistUrl(bugs.map((bug) => bug.id)); // add to dom const $template = _("#bug-row-template"); let i = 0; for (const bug of bugs) { // main row const $row = cloneTemplate($template); updateTemplate($row, bug); // replace the timestamp cell const $timestamp = cloneTemplate(buglist.$timestampTemplate); updateTemplate($timestamp, bug); _($row, ".timestamp").append($timestamp); // set odd/even class for (const $tr of __($row, "tr")) { $tr.classList.add(i % 2 === 0 ? "odd" : "even"); } if (buglist.augmentRow) { buglist.augmentRow($row); } i++; $list.append($row); } } else { buglist.$root.classList.add("closed"); buglist.$root.classList.add("no-bugs"); _(buglist.$root, ".buglist-header .counter").textContent = "No bugs"; _(buglist.$root, ".buglist-header .order-btn").disabled = true; _(buglist.$root, ".buglist-header .buglist-btn").disabled = true; } buglist.$root.classList.remove("loading"); }