Created
December 9, 2025 16:36
-
-
Save hsayed21/5535f6a946143a3519230c8bc42704e0 to your computer and use it in GitHub Desktop.
Show badges in Azure DevOps PR list
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Azure DevOps PR Reviewer Badges | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.0 | |
| // @description Show Approved / No Update / Assigned / Not Assigned badges in Azure DevOps PR list | |
| // @author hsayed21 | |
| // @match https://dev.azure.com/*/*/_git/*/pullrequests* | |
| // @grant GM_xmlhttpRequest | |
| // @connect dev.azure.com | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| const CURRENT_USER_EMAIL = "<EMAIL-HERE>"; | |
| function getOrgProjectRepo() { | |
| const parts = window.location.pathname.split('/').filter(Boolean); | |
| return { | |
| organization: parts[0], | |
| project: parts[1], | |
| repo: decodeURIComponent(parts[3]) | |
| }; | |
| } | |
| function addBadge(container, text, color) { | |
| const badge = document.createElement("span"); | |
| badge.textContent = text; | |
| badge.style.cssText = ` | |
| background:${color}; | |
| color:white; | |
| padding:2px 6px; | |
| border-radius:6px; | |
| margin-left:8px; | |
| font-size:11px; | |
| font-weight:bold; | |
| display:inline-block; | |
| `; | |
| container.appendChild(badge); | |
| } | |
| function api(url) { | |
| return new Promise(resolve => { | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url, | |
| headers: { "Content-Type": "application/json" }, | |
| onload: (r) => resolve(JSON.parse(r.responseText)), | |
| onerror: () => resolve(null) | |
| }); | |
| }); | |
| } | |
| async function hasUpdates(org, project, repo, prId, myEmail) { | |
| const { lastActivity } = await getMyLastActivity(org, project, repo, prId, myEmail); | |
| // 1) Check new commits | |
| const iterations = await api(`https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/iterations?api-version=7.0`); | |
| if (iterations?.value?.length) { | |
| const lastIter = iterations.value[iterations.value.length - 1]; | |
| const lastCommitTime = lastIter.createdDate; | |
| if (new Date(lastCommitTime) > new Date(lastActivity)) { | |
| return true; // New commits added | |
| } | |
| } | |
| // 2) Check new comments | |
| const threads = await api(`https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/threads?api-version=7.0`); | |
| if (threads?.value?.length) { | |
| for (const t of threads.value) { | |
| for (const c of t.comments) { | |
| if (new Date(c.publishedDate) > new Date(lastActivity)) { | |
| return true; // New comment | |
| } | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| async function getMyLastActivity(org, project, repo, prId, myEmail) { | |
| let lastVoteDate = null; | |
| let lastCommitDate = null; | |
| let lastCommentDate = null; | |
| // ---- 1) Reviewer Info (contains your vote timestamps) | |
| const reviewers = await api(`https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/reviewers?api-version=7.0`); | |
| if (reviewers?.value?.length) { | |
| const me = reviewers.value.find(r => r.uniqueName === myEmail); | |
| if (me?.updatedDate) lastVoteDate = new Date(me.updatedDate); | |
| } | |
| // ---- 2) Commits | |
| const commits = await api(`https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/iterations?api-version=7.0`); | |
| if (commits?.value?.length) { | |
| for (const commit of commits.value) { | |
| if (commit.author?.uniqueName === myEmail) { | |
| const d = new Date(commit.createdDate); | |
| if (!lastCommitDate || d > lastCommitDate) { | |
| lastCommitDate = d; | |
| } | |
| } | |
| } | |
| } | |
| // ---- 3) Commnets | |
| const threads = await api(`https://dev.azure.com/${org}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/threads?api-version=7.0`); | |
| if (threads?.value?.length) { | |
| for (const thread of threads.value) { | |
| for (const c of thread.comments) { | |
| if (c.author?.uniqueName === myEmail) { | |
| const d = new Date(c.publishedDate); | |
| if (!lastCommentDate || d > lastCommentDate) { | |
| lastCommentDate = d; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // ---- 4) Determine the latest activity (largest datetime) | |
| const lastActivity = getLatestDate(lastVoteDate, lastCommitDate, lastCommentDate); | |
| return { | |
| lastVoteDate, | |
| lastCommitDate, | |
| lastCommentDate, | |
| lastActivity | |
| }; | |
| } | |
| function getLatestDate(...dates) { | |
| const valid = dates.filter(d => d).map(d => new Date(d)); | |
| if (valid.length === 0) return null; | |
| return new Date(Math.max(...valid)); | |
| } | |
| async function processRow(row) { | |
| if (row.dataset.badgeDone === "yes") return; | |
| row.dataset.badgeDone = "yes"; | |
| // Get PR ID from href | |
| const prMatch = row.href.match(/pullrequest\/(\d+)/); | |
| if (!prMatch) return; | |
| const prId = prMatch[1]; | |
| const { organization, project, repo } = getOrgProjectRepo(); | |
| const prInfo = await api(`https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}?api-version=7.0`); | |
| const isDraft = prInfo?.isDraft === true; | |
| if (isDraft) | |
| { | |
| row.style.display="none"; | |
| } | |
| const isMyPR = prInfo?.createdBy?.uniqueName === CURRENT_USER_EMAIL; | |
| if (isMyPR) | |
| { | |
| row.style.background = "rgb(17, 84, 87)"; | |
| return; | |
| } | |
| const container = row.querySelector(".bolt-table-two-line-cell-item.flex-row.scroll-hidden"); | |
| if (!container) return; | |
| const reviewersData = await api(`https://dev.azure.com/${organization}/${project}/_apis/git/repositories/${repo}/pullRequests/${prId}/reviewers?api-version=7.0`); | |
| if (!reviewersData || !reviewersData.value) return; | |
| const reviewers = reviewersData.value || []; | |
| const myReviewer = reviewers.find(r => r.uniqueName === CURRENT_USER_EMAIL); | |
| if (!myReviewer) { | |
| addBadge(container, "Not Assigned", "gray"); | |
| return; | |
| } | |
| // Reviewer status badges | |
| const vote = myReviewer.vote; | |
| if (vote === 10 || vote === 5) { | |
| addBadge(container, "Approved", "green"); | |
| return; | |
| } | |
| else if (vote === -5) { | |
| addBadge(container, "Waiting Author", "orange"); | |
| } | |
| else { | |
| addBadge(container, "Assigned", "rgb(5, 89, 166)"); // gray-ish | |
| } | |
| // ► Check new updates AFTER review | |
| if (vote !== 0) { | |
| const updated = await hasUpdates(organization, project, repo, prId, CURRENT_USER_EMAIL); | |
| if (updated) { | |
| addBadge(container, "Updated", "dodgerblue"); | |
| } else { | |
| addBadge(container, "No Updates", "#555"); | |
| } | |
| } | |
| } | |
| // Observe UI changes (Azure DevOps loads dynamically) | |
| const observer = new MutationObserver(() => { | |
| document.querySelectorAll("table.repos-pr-list > tbody > a").forEach(row => processRow(row)); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| // Initial run | |
| setTimeout(() => { | |
| document.querySelectorAll("table.repos-pr-list > tbody > a").forEach(row => processRow(row)); | |
| }, 1200); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment