Azure DevOps Work Item History Diff latest version (currently v2.0.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Azure DevOps: Work Item History Diff
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.0.0
// @description  On a work item's History tab, lists the fields each change touched and adds a "View diff" button to long-text field changes, showing the old and new values as a single formatted diff
// @author       Tim Hilton using Claude
// @match        https://dev.azure.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const LOG_PREFIX = '[Azure DevOps: Work Item History Diff]';
    const BUTTON_CLASS = 'wihd-button';

    const STYLES = `
.${BUTTON_CLASS} {
    margin-left: 8px;
    padding: 2px 8px;
    font-size: 12px;
    font-weight: normal;
    vertical-align: middle;
    color: inherit;
    background: transparent;
    border: 1px solid currentColor;
    border-radius: 2px;
    cursor: pointer;
    opacity: 0.8;

    &:hover {
        opacity: 1;
    }
}

.wihd-fields {
    flex: 1;
    min-width: 0;
    margin-right: 8px;
    overflow: hidden;
    font-size: 12px;
    text-align: left;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--text-secondary-color, #666);
}

.wihd-dialog {
    width: min(1100px, 90vw);
    max-height: 85vh;
    padding: 0;
    color: var(--text-primary-color, #000);
    background: var(--background-color, #fff);
    border: 1px solid rgba(var(--palette-neutral-8, 0, 0, 0), 0.5);
    border-radius: 4px;

    &::backdrop {
        background: rgba(0, 0, 0, 0.5);
    }

    header {
        position: sticky;
        top: 0;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 12px 16px;
        font-size: 16px;
        font-weight: 600;
        background: inherit;
        border-bottom: 1px solid rgba(var(--palette-neutral-8, 0, 0, 0), 0.5);
    }

    header button {
        font-size: 20px;
        line-height: 1;
        color: inherit;
        background: none;
        border: none;
        cursor: pointer;
    }

    .wihd-body {
        padding: 8px 16px 16px;
    }

    ins {
        text-decoration: none;
        background: rgba(46, 160, 67, 0.35);
    }

    del {
        text-decoration: line-through;
        background: rgba(248, 81, 73, 0.35);
    }

    mark {
        color: inherit;
        background: rgba(210, 153, 34, 0.4);
    }

    .wihd-legend {
        margin: 0 16px 0 auto;
        font-size: 12px;
        font-weight: normal;

        > * {
            margin-left: 8px;
            padding: 0 4px;
        }
    }
}
`;

    // Longest-common-subsequence diff of two token arrays, returned as runs of
    // { type: 'same' | 'removed' | 'added', value: tokens[] } with removals before additions.
    // A library such as jsdiff can't be @require'd here: Azure DevOps's AMD loader captures its
    // UMD build, so it never becomes a global.
    // ponytail: O(n×m) table after trimming the common start and end, fine for field-sized text;
    // switch to Myers' algorithm if very long fields with scattered edits become slow.
    function diffArrays(a, b) {
        let start = 0;
        while (start < a.length && start < b.length && a[start] === b[start]) {
            start++;
        }
        let endA = a.length;
        let endB = b.length;
        while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
            endA--;
            endB--;
        }
        // Only whole trailing blocks are trimmed from the end: matching part of a block greedily from
        // the end can pair a full stop with one in a newly added list item instead of its own item.
        while (endA < a.length && a[endA] !== BOUNDARY) {
            endA++;
            endB++;
        }

        // lcs[i * w + j] is the LCS length of a[start + i, endA) and b[start + j, endB)
        const n = endA - start;
        const m = endB - start;
        const w = m + 1;
        const lcs = new Uint32Array((n + 1) * w);
        for (let i = n - 1; i >= 0; i--) {
            for (let j = m - 1; j >= 0; j--) {
                lcs[i * w + j] =
                    a[start + i] === b[start + j]
                        ? lcs[(i + 1) * w + j + 1] + 1
                        : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]);
            }
        }

        const parts = [];
        const push = (type, token) => {
            const last = parts.at(-1);
            if (last?.type === type) {
                last.value.push(token);
            } else {
                parts.push({ type, value: [token] });
            }
        };
        a.slice(0, start).forEach((token) => push('same', token));
        let i = 0;
        let j = 0;
        while (i < n || j < m) {
            if (i < n && j < m && a[start + i] === b[start + j]) {
                push('same', a[start + i]);
                i++;
                j++;
            } else if (j === m || (i < n && lcs[(i + 1) * w + j] >= lcs[i * w + j + 1])) {
                push('removed', a[start + i]);
                i++;
            } else {
                push('added', b[start + j]);
                j++;
            }
        }
        a.slice(endA).forEach((token) => push('same', token));

        // An insertion or removal can often sit in several equally valid places. Slide each one as
        // late as it will go, so a new list item is reported whole rather than straddling the end of
        // the previous item (with the old full stop matched to the new one). If that leaves a run that
        // spans a block boundary without starting on one, slide it back until it does, so a removed
        // paragraph is reported as its own paragraph rather than merged into the start of the next.
        for (let k = 1; k < parts.length - 1; k++) {
            const [prev, run, next] = parts.slice(k - 1, k + 2);
            if (prev.type !== 'same' || run.type === 'same' || next.type !== 'same') {
                continue;
            }
            while (next.value.length > 0 && run.value[0] === next.value[0]) {
                prev.value.push(run.value.shift());
                run.value.push(next.value.shift());
            }
            while (
                run.value[0] !== BOUNDARY &&
                run.value.includes(BOUNDARY) &&
                prev.value.length > 0 &&
                run.value.at(-1) === prev.value.at(-1)
            ) {
                next.value.unshift(run.value.pop());
                run.value.unshift(prev.value.pop());
            }
        }
        return parts;
    }

    // Key for block boundaries: the Unicode paragraph separator, which never appears as a word
    const BOUNDARY = String.fromCharCode(0x2029);
    const BLOCKS = new Set(['blockquote', 'dd', 'div', 'dl', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ol', 'p', 'pre', 'table', 'td', 'th', 'tr', 'ul']);
    const VOIDS = new Set(['br', 'hr', 'img', 'input']);

    // Splits HTML into words, runs of whitespace and single punctuation characters, each remembering
    // the elements it sits in (its "chain"). Void elements (line breaks, images, checkboxes) are
    // tokens of their own, and each block starts with a BOUNDARY token so paragraphs and list items
    // line up with each other in the diff.
    function tokenise(html) {
        const template = document.createElement('template');
        template.innerHTML = html;
        const tokens = [];
        const walk = (node, chain) => {
            for (const child of node.childNodes) {
                if (child.nodeType === Node.TEXT_NODE) {
                    for (const text of child.textContent.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? []) {
                        tokens.push({ key: text, text, chain });
                    }
                } else if (child.nodeType === Node.ELEMENT_NODE && VOIDS.has(child.localName)) {
                    tokens.push({ key: child.outerHTML, html: child.outerHTML, chain });
                } else if (child.nodeType === Node.ELEMENT_NODE) {
                    const childChain = [...chain, child];
                    if (BLOCKS.has(child.localName)) {
                        tokens.push({ key: BOUNDARY, text: '', chain: childChain });
                    }
                    walk(child, childChain);
                }
            }
        };
        walk(template.content, []);
        return tokens;
    }

    const MARKS = {
        added: ['', ''],
        removed: ['', ''],
        format: ['', ''],
    };

    const escapeHtml = (text) => text.replace(/&/g, '&').replace(//g, '>');

    const openTag = (element) => {
        const html = element.cloneNode(false).outerHTML;
        return html.slice(0, html.lastIndexOf(' elements.reverse().map((element) => ``).join('');

    // Rebuilds HTML from tokens, opening and closing elements whenever the chain changes from one
    // token to the next, so the output is well formed however much the structure changed.
    function render(tokens) {
        let html = '';
        const stack = [];
        let mark = null;
        const closeMark = () => {
            if (mark) {
                html += mark[1];
                mark = null;
            }
        };

        for (const token of tokens) {
            let depth = 0;
            while (depth < stack.length && stack[depth] === token.chain[depth]) {
                depth++;
            }
            if (depth < stack.length || depth < token.chain.length) {
                closeMark();
                html += closeTags(stack.splice(depth));
                for (const element of token.chain.slice(depth)) {
                    html += openTag(element);
                    stack.push(element);
                }
            }

            const content = token.html ?? escapeHtml(token.text);
            if (!content) {
                continue;
            }
            const wanted = MARKS[token.type] ?? null;
            if (wanted !== mark) {
                closeMark();
                mark = wanted;
                html += mark?.[0] ?? '';
            }
            html += content;
        }
        closeMark();
        return html + closeTags(stack);
    }

    // Formatting that matters for comparison: element names, plus link targets
    const signature = (chain) => chain.map((element) => element.localName + (element.getAttribute('href') ?? '')).join('>');

    // Whitespace and punctuation only: no words, void elements or block boundaries
    const isFiller = (token) => token.text !== undefined && token.key !== BOUNDARY && !/[\p{L}\p{N}]/u.test(token.text);

    const isWord = (token) => token.text !== undefined && /^\S+$/.test(token.text);

    // Orders a group's removed and added tokens, removed first where possible, but never letting one
    // side close an element (e.g. a list item) the other still has text to show in, as rendering
    // would then have to open a second copy of it. `chain` is where the previous token left off.
    function interleave(removed, added, chain) {
        const closesNeeded = (token, other) => {
            let depth = 0;
            while (depth < chain.length && chain[depth] === token.chain[depth]) {
                depth++;
            }
            return chain.slice(depth).some((element) => other.chain.includes(element));
        };
        const result = [];
        let r = 0;
        let a = 0;
        while (r < removed.length || a < added.length) {
            const takeRemoved =
                r < removed.length &&
                (a >= added.length || !closesNeeded(removed[r], added[a]) || closesNeeded(added[a], removed[r]));
            const token = takeRemoved ? removed[r] : added[a];
            if (takeRemoved) {
                r++;
            } else {
                a++;
            }
            result.push(token);
            chain = token.chain;
        }
        return result;
    }

    // A single word changed into a similar word (e.g. "authorization" -> "authorisation") is shown
    // character by character; anything else is shown as the old words struck out followed by the new.
    function markReplacement(removed, added, chain) {
        if (removed.length === 1 && added.length === 1 && isWord(removed[0]) && isWord(added[0])) {
            const [from, to] = [removed[0], added[0]];
            const chars = diffArrays([...from.text], [...to.text]);
            const common = chars.filter((c) => c.type === 'same').reduce((n, c) => n + c.value.length, 0);
            if (common * 2 >= Math.max(from.text.length, to.text.length)) {
                return chars.map((c) => ({ text: c.value.join(''), chain: c.type === 'removed' ? from.chain : to.chain, type: c.type }));
            }
        }
        return interleave(removed, added, chain);
    }

    function diffHtml(oldHtml, newHtml) {
        const oldTokens = tokenise(oldHtml);
        const newTokens = tokenise(newHtml);
        const parts = diffArrays(
            oldTokens.map((token) => token.key),
            newTokens.map((token) => token.key),
        );

        // Swap each part's keys back for the tokens they came from
        let i = 0;
        let j = 0;
        for (const part of parts) {
            const count = part.value.length;
            part.old = part.type === 'added' ? [] : oldTokens.slice(i, i + count);
            part.new = part.type === 'removed' ? [] : newTokens.slice(j, j + count);
            i += part.old.length;
            j += part.new.length;
        }

        // Pair up old and new elements that hold the same text, so a removed word is shown inside the
        // paragraph it was removed from rather than in a separate copy of it
        const counterpart = new Map();
        for (const part of parts.filter((p) => p.type === 'same')) {
            part.old.forEach((oldToken, k) => {
                const newChain = part.new[k].chain;
                for (let d = 0; d < Math.min(oldToken.chain.length, newChain.length); d++) {
                    if (oldToken.chain[d].localName !== newChain[d].localName) {
                        break;
                    }
                    if (!counterpart.has(oldToken.chain[d])) {
                        counterpart.set(oldToken.chain[d], newChain[d]);
                    }
                }
            });
        }
        const asRemoved = (token) => ({ ...token, chain: token.chain.map((element) => counterpart.get(element) ?? element), type: 'removed' });
        const asAdded = (token) => ({ ...token, type: 'added' });

        const output = [];
        let removed = [];
        let added = [];
        const flush = () => {
            output.push(...markReplacement(removed, added, output.at(-1)?.chain ?? []));
            removed = [];
            added = [];
        };

        for (let p = 0; p < parts.length; p++) {
            const part = parts[p];
            if (part.type !== 'same') {
                removed.push(...part.old.map(asRemoved));
                added.push(...part.new.map(asAdded));
            } else if (removed.length + added.length > 0 && part.old.every(isFiller) && parts[p + 1] && parts[p + 1].type !== 'same') {
                // Changes separated only by spaces or punctuation are one rewritten phrase, so it's shown
                // as "~~old old old~~ new new new" rather than alternating word by word
                removed.push(...part.old.map(asRemoved));
                added.push(...part.new.map(asAdded));
            } else {
                flush();
                // Unchanged text is shown with its new formatting, highlighted if that formatting
                // (bold, code, link, list type, ...) differs from before
                part.new.forEach((token, k) => {
                    output.push({ ...token, type: signature(token.chain) === signature(part.old[k].chain) ? 'same' : 'format' });
                });
            }
        }
        flush();
        return render(output);
    }

    function openDiff(field, title) {
        const oldHtml = field.querySelector('.removed > del')?.innerHTML ?? '';
        const newHtml = field.querySelector('.added > ins')?.innerHTML ?? '';
        console.debug(`${LOG_PREFIX} Diffing "${title}" (${oldHtml.length} -> ${newHtml.length} chars)`);

        const dialog = document.createElement('dialog');
        dialog.className = 'wihd-dialog';
        dialog.innerHTML = `
AddedRemovedFormatting changed
`; dialog.querySelector('header span').textContent = `${title} changes`; dialog.querySelector('.wihd-body').innerHTML = diffHtml(oldHtml, newHtml); dialog.querySelector('header button').addEventListener('click', () => dialog.close()); // A click directly on the element (rather than its content) is a click on the backdrop dialog.addEventListener('click', (e) => e.target === dialog && dialog.close()); dialog.addEventListener('close', () => dialog.remove()); document.body.append(dialog); dialog.showModal(); console.log(`${LOG_PREFIX} ✅ Opened diff for "${title}"`); } function addButtons() { for (const field of document.querySelectorAll('.html-field-diff')) { const heading = field.firstElementChild; if (!heading || heading.querySelector(`.${BUTTON_CLASS}`)) { continue; } const button = document.createElement('button'); button.type = 'button'; button.className = BUTTON_CLASS; button.textContent = 'View diff'; // Azure DevOps reuses these elements when another revision is selected, so the title and // values are read at click time rather than captured now. button.addEventListener('click', () => openDiff(field, heading.firstChild.textContent.trim())); heading.append(button); console.debug(`${LOG_PREFIX} Added diff button for "${heading.firstChild.textContent.trim()}"`); } } // Fields Azure DevOps leaves out of a revision's details: bookkeeping it updates on every save, and // each board's own copy of the board column (shown once as "Board Column") // ponytail: observed on real work items rather than documented; add any other hidden field that slips through const HIDDEN_FIELDS = new Set([ 'System.Rev', 'System.Watermark', 'System.PersonId', 'System.AuthorizedDate', 'System.AuthorizedAs', 'System.RevisedDate', 'System.ChangedDate', 'System.ChangedBy', 'Microsoft.VSTS.Common.StateChangeDate', 'Microsoft.VSTS.Common.ActivatedDate', ]); const isHidden = (referenceName) => HIDDEN_FIELDS.has(referenceName) || referenceName.startsWith('WEF_'); const getJson = async (path) => { const organisation = location.pathname.split('/')[1]; const response = await fetch(`/${organisation}/_apis/wit/${path}api-version=7.1`); if (!response.ok) { throw new Error(`${response.status} from ${path}`); } return response.json(); }; let fieldDefinitions; const changesByWorkItem = new Map(); // Resolves to a Map from each revision's change time to the display names of the fields it changed, // in the order the revision's details list them: other fields first, then long-text fields function getChangedFields(workItemId) { if (!changesByWorkItem.has(workItemId)) { console.debug(`${LOG_PREFIX} Fetching updates for work item ${workItemId}`); fieldDefinitions ??= getJson('fields?').then( (fields) => new Map(fields.value.map((field) => [field.referenceName, field])), ); const changes = Promise.all([fieldDefinitions, getJson(`workItems/${workItemId}/updates?`)]).then( ([definitions, updates]) => new Map( updates.value .filter((update) => update.fields?.['System.ChangedDate']) .map((update) => [ Date.parse(update.fields['System.ChangedDate'].newValue), Object.keys(update.fields) .filter((referenceName) => !isHidden(referenceName) && definitions.has(referenceName)) .map((referenceName) => definitions.get(referenceName)) .sort((a, b) => (a.type === 'html') - (b.type === 'html')) .map((field) => field.name), ]), ), (error) => { console.log(`${LOG_PREFIX} ❌ Couldn't fetch changed fields: ${error.message}`); return new Map(); }, ); changesByWorkItem.set(workItemId, changes); } return changesByWorkItem.get(workItemId); } const refetchedTimes = new Set(); // Writes the changed fields' names under each "... made field changes" summary in the history list async function addFieldNames() { const workItemId = location.href.match(/_workitems\/edit\/(\d+)|[?&]workitem=(\d+)/)?.slice(1).find(Boolean); if (!workItemId) { return; } const rows = [...document.querySelectorAll('tr.bolt-list-row')].filter((row) => row.textContent.includes('made field changes')); for (const row of rows) { const time = row.querySelector('time')?.getAttribute('datetime'); const line = row.querySelector('.update-list-item-second-line'); // The list recycles rows as it scrolls, so a label is checked against its row's time if (!time || !line || line.querySelector('.wihd-fields')?.dataset.time === time) { continue; } let names = (await getChangedFields(workItemId)).get(Date.parse(time)); if (!names && !refetchedTimes.has(time)) { // A revision saved since the updates were fetched refetchedTimes.add(time); changesByWorkItem.delete(workItemId); names = (await getChangedFields(workItemId)).get(Date.parse(time)); } if (!names?.length) { continue; } let label = line.querySelector('.wihd-fields'); if (!label) { label = document.createElement('span'); label.className = 'wihd-fields'; line.prepend(label); } label.dataset.time = time; label.textContent = names.join(', '); label.title = label.textContent; console.debug(`${LOG_PREFIX} Listed changed fields for ${time}: ${label.textContent}`); } } const scan = () => { addButtons(); addFieldNames(); }; const style = document.createElement('style'); style.textContent = STYLES; document.head.append(style); scan(); let scanScheduled = false; new MutationObserver(() => { if (scanScheduled) { return; } scanScheduled = true; requestAnimationFrame(() => { scanScheduled = false; scan(); }); }).observe(document.body, { childList: true, subtree: true }); console.debug(`${LOG_PREFIX} Script initialized`); })();