Skip to content

Instantly share code, notes, and snippets.

@Gabrielcarvfer
Last active October 7, 2026 13:07
Show Gist options
  • Select an option

  • Save Gabrielcarvfer/bbd3e4826148934449d4e963e1ea79d3 to your computer and use it in GitHub Desktop.

Select an option

Save Gabrielcarvfer/bbd3e4826148934449d4e963e1ea79d3 to your computer and use it in GitHub Desktop.
GitLab: Open/Closed items instead of Work items
// ==UserScript==
// @name GitLab: Open/Closed items instead of Work items
// @namespace gitlab-open-closed-items
// @version 1.2.1
// @description Replaces "Work items" in the left sidebar with "Open issues" and "Closed issues" links
// @match https://gitlab.com/*
// @match https://gitlab.example.org/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
/* ======================================================================
* SETTINGS
* ==================================================================== */
// Which list the new buttons open. '/-/issues' = classic issue list,
// '/-/work_items' = the work items list, just pre-filtered by state.
const LIST_PATH = '/-/issues';
// count: show that state's issue count in the badge (false = no badge)
const BUTTONS = [
{ label: 'Open issues', state: 'opened', count: true },
{ label: 'Closed issues', state: 'closed', count: true },
];
const DEBUG = false;
/* ====================================================================== */
const MARK = 'data-oci-clone';
const HIDDEN = 'data-oci-hidden';
const WORK_ITEMS_RE = /\/-\/work_items\/?(?:[?#].*)?$/;
const log = (...a) => DEBUG && console.info('[open-closed-items]', ...a);
// original sidebar item (li) -> { clones: [li, li], urls: [URL, URL] }
const registry = new Map();
function findWorkItemsLinks() {
const root = document.getElementById('super-sidebar') || document;
return [...root.querySelectorAll('a[href]')].filter((a) => {
if (a.closest(`[${MARK}]`)) return false;
return WORK_ITEMS_RE.test(a.getAttribute('href'));
});
}
function setLabel(a, text) {
const walker = document.createTreeWalker(a, NodeFilter.SHOW_TEXT);
let done = false;
for (let n; (n = walker.nextNode()); ) {
if (/work items/i.test(n.data)) {
n.data = n.data.replace(/work items/i, text);
done = true;
}
}
if (!done) {
const label = a.querySelector('[data-testid="nav-item-link-label"]');
if (label) label.textContent = text;
}
for (const el of [a, ...a.querySelectorAll('[title], [aria-label], [data-qa-submenu-item]')]) {
if (el.hasAttribute('title')) el.setAttribute('title', text);
if (el.hasAttribute('aria-label')) el.setAttribute('aria-label', text);
if (el.hasAttribute('data-qa-submenu-item')) el.setAttribute('data-qa-submenu-item', text);
}
}
const BADGE_SEL = '[data-testid="pill-badge"], .nav-item-link-badge, .badge, .gl-badge';
function removeBadge(a) {
a.querySelectorAll(BADGE_SEL).forEach((b) => b.remove());
}
function setBadge(a, text) {
let badge = a.querySelector(BADGE_SEL);
if (!badge) {
badge = document.createElement('span');
badge.className = 'nav-item-link-badge gl-mr-3 gl-text-sm';
badge.setAttribute('data-testid', 'pill-badge');
const slot = document.createElement('span');
slot.className = 'gl-nav-item-slot';
slot.setAttribute('data-testid', 'nav-item-end');
slot.appendChild(badge);
a.appendChild(slot);
}
badge.textContent = text;
}
// 1234 -> "1.2k", like GitLab's own badges
function formatCount(n) {
if (n < 1000) return String(n);
if (n < 1e6) return `${+(n / 1000).toFixed(1)}k`;
return `${+(n / 1e6).toFixed(1)}m`;
}
/* ---------- issue counts (GraphQL, one request per project/group per page) ---------- */
const countCache = new Map(); // "kind:path:state" -> Promise<number | null>
// "/group/sub/project/-/work_items" -> { kind: 'project', path }
// "/groups/g/sub/-/work_items" -> { kind: 'group', path }
function namespaceOf(workItemsHref) {
const root = (window.gon && gon.relative_url_root) || '';
let path = new URL(workItemsHref, location.origin).pathname.replace(/\/-\/work_items\/?$/, '');
if (root && path.startsWith(root)) path = path.slice(root.length);
const m = path.match(/^\/groups\/(.+)$/);
return m ? { kind: 'group', path: m[1] } : { kind: 'project', path: path.replace(/^\//, '') };
}
function getCount(workItemsHref, state) {
const { kind, path } = namespaceOf(workItemsHref);
const key = `${kind}:${path}:${state}`;
if (!countCache.has(key)) {
const sub = kind === 'group' ? ', includeSubgroups: true' : '';
const query = `query($path: ID!) { ns: ${kind}(fullPath: $path) { issues(state: ${state}${sub}) { count } } }`;
const root = (window.gon && gon.relative_url_root) || '';
const csrf = document.querySelector('meta[name="csrf-token"]');
countCache.set(key, fetch(`${root}/api/graphql`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...(csrf ? { 'X-CSRF-Token': csrf.content } : {}) },
body: JSON.stringify({ query, variables: { path } }),
})
.then((r) => (r.ok ? r.json() : null))
.then((j) => (j && j.data && j.data.ns && j.data.ns.issues ? j.data.ns.issues.count : null))
.catch(() => null));
}
return countCache.get(key);
}
// Active state: GitLab marks the current item with aria-current and
// "...-current"/"...-active" classes. Only those are touched.
const ACTIVE_CLASS_RE = /(^|-)(current|active)$/i;
function setActive(a, active) {
if (active) {
a.setAttribute('aria-current', 'page');
return;
}
a.removeAttribute('aria-current');
for (const el of [a, ...a.querySelectorAll('[class]')]) {
for (const c of [...el.classList]) if (ACTIVE_CLASS_RE.test(c)) el.classList.remove(c);
}
}
function buildUrl(workItemsHref, state) {
const url = new URL(workItemsHref, location.origin);
url.pathname = url.pathname.replace(/\/-\/work_items\/?$/, LIST_PATH);
url.search = '';
url.hash = '';
url.searchParams.set('state', state);
return url;
}
function isCurrent(url) {
const here = new URL(location.href);
const samePath = here.pathname.replace(/\/$/, '') === url.pathname.replace(/\/$/, '');
const hereState = here.searchParams.get('state') || 'opened';
return samePath && hereState === url.searchParams.get('state');
}
function makeClones(item, link) {
const href = link.getAttribute('href');
const clones = [];
const urls = [];
for (const btn of BUTTONS) {
const clone = item.cloneNode(true);
clone.setAttribute(MARK, btn.state);
clone.removeAttribute(HIDDEN);
clone.style.removeProperty('display');
clone.classList.remove('js-draggable-item'); // not part of pin reordering
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
const a = clone.matches('a') ? clone : clone.querySelector('a[href]');
a.classList.remove('shortcuts-issues'); // keep GitLab's "g i" shortcut on the original
const url = buildUrl(href, btn.state);
a.setAttribute('href', url.pathname + url.search);
setLabel(a, btn.label);
setActive(a, isCurrent(url));
if (btn.count) {
getCount(href, btn.state).then((n) => {
if (n != null) setBadge(a, formatCount(n));
});
} else {
removeBadge(a);
}
clones.push(clone);
urls.push(url);
}
return { clones, urls };
}
/** Make sure every original is hidden and followed by its clones, in order. */
function ensure() {
// forget originals Vue threw away
for (const [item, entry] of registry) {
if (!item.isConnected) {
entry.clones.forEach((c) => c.remove());
registry.delete(item);
}
}
// register new originals
for (const link of findWorkItemsLinks()) {
const item = link.closest('li') || link;
if (registry.has(item)) continue;
registry.set(item, makeClones(item, link));
log('replaced', link.getAttribute('href'));
}
// enforce placement / visibility (cheap no-op when already right)
for (const [item, { clones, urls }] of registry) {
if (item.style.display !== 'none') item.style.display = 'none';
if (!item.hasAttribute(HIDDEN)) item.setAttribute(HIDDEN, '');
let prev = item;
clones.forEach((clone, i) => {
if (prev.nextElementSibling !== clone) prev.after(clone);
const a = clone.matches('a') ? clone : clone.querySelector('a[href]');
const cur = isCurrent(urls[i]);
if (cur !== (a.getAttribute('aria-current') === 'page')) setActive(a, cur);
prev = clone;
});
}
}
let pending = false;
new MutationObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
ensure();
});
}).observe(document.documentElement, { childList: true, subtree: true });
ensure();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment