Zendesk — Add link to Azure DevOps work item in header v1.1.0
Adds a button to the ticket header that links to the Azure DevOps work item.
Script Content
// ==UserScript==
// @name Zendesk — Add link to Azure DevOps work item in header
// @namespace http://tampermonkey.net/
// @version 1.1.0
// @description Adds a button to the ticket header that links to the Azure DevOps work item.
// @author tjhleeds using Jules
// @match https://*.zendesk.com/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = '[Zendesk to Azure DevOps Userscript]';
console.log(`${LOG_PREFIX} Script initialized`);
const AZURE_DEVOPS_SVG = `
`;
// Track which ticket+URL combinations we've processed
const processedTickets = new Map(); // ticketId -> devopsUrl
const findTicketContainer = (ticketId) => {
// Find the container that holds this specific ticket's content
// This is usually a parent of the header with data-ticket-id
const header = document.querySelector(`div[data-ticket-id="${ticketId}"]`);
if (!header) return null;
// Look for a container that likely holds both header and form fields
// Try to find a parent with a test-id or class that indicates it's a ticket container
let container = header;
while (container && container !== document.body) {
const testId = container.getAttribute('data-test-id');
if (testId && testId.includes(`ticket-${ticketId}`)) {
return container;
}
container = container.parentElement;
}
// If we can't find a specific container, return the header's parent
return header.parentElement;
};
const getCurrentTicketId = () => {
// Check the URL to see which ticket is currently active
const urlMatch = window.location.href.match(/\/tickets\/(\d+)/);
if (urlMatch) {
return urlMatch[1];
}
return null;
};
const addLinkToTicket = (ticketId) => {
console.log(`${LOG_PREFIX} addLinkToTicket() called for ticket ${ticketId}`);
// Only process the currently active ticket
const currentTicketId = getCurrentTicketId();
if (currentTicketId && currentTicketId !== ticketId) {
console.log(`${LOG_PREFIX} ⏭️ Skipping ticket ${ticketId} (not currently active, active is ${currentTicketId})`);
return false;
}
// Find the specific ticket header
const header = document.querySelector(`div[data-ticket-id="${ticketId}"]`);
if (!header) {
console.log(`${LOG_PREFIX} ❌ Header not found for ticket ${ticketId}`);
return false;
}
console.log(`${LOG_PREFIX} ✅ Header found for ticket ${ticketId}`);
// Check if we've already added a link to this specific header
const existingLink = header.querySelector('.azure-devops-link');
if (existingLink) {
console.log(`${LOG_PREFIX} ⏭️ Link already exists in this header, skipping`);
return true; // Already processed successfully
}
// Find the ticket container to search within
const container = findTicketContainer(ticketId);
console.log(`${LOG_PREFIX} Searching for DevOps Link field within container for ticket ${ticketId}`);
// Search for DevOps Link field within the container (or globally if we can't find container)
const searchRoot = container || document;
const labels = Array.from(searchRoot.querySelectorAll('label'));
const devopsLinkField = labels.find(label => label.textContent === 'DevOps Link');
if (!devopsLinkField) {
console.log(`${LOG_PREFIX} ❌ DevOps Link field not found for ticket ${ticketId}`);
return false;
}
console.log(`${LOG_PREFIX} ✅ DevOps Link field found`);
const devopsLinkInput = devopsLinkField.nextElementSibling;
if (!devopsLinkInput || !devopsLinkInput.value) {
console.log(`${LOG_PREFIX} ❌ DevOps Link input not found or empty`, devopsLinkInput);
return false;
}
console.log(`${LOG_PREFIX} ✅ DevOps Link value: ${devopsLinkInput.value}`);
// Check if we've already processed this exact ticket+URL combination
const previousUrl = processedTickets.get(ticketId);
if (previousUrl === devopsLinkInput.value) {
console.log(`${LOG_PREFIX} ⏭️ Already processed ticket ${ticketId} with this URL`);
return true;
}
const firstChildOfHeader = header.firstChild;
if (!firstChildOfHeader) {
console.log(`${LOG_PREFIX} ❌ Header has no first child`);
return false;
}
const targetElement = firstChildOfHeader.firstChild;
if (!targetElement) {
console.log(`${LOG_PREFIX} ❌ First child has no first child`);
return false;
}
const link = document.createElement('a');
link.href = devopsLinkInput.value;
link.target = '_blank';
link.classList.add('azure-devops-link');
link.innerHTML = AZURE_DEVOPS_SVG;
firstChildOfHeader.insertBefore(link, targetElement);
firstChildOfHeader.style.display = 'flex';
firstChildOfHeader.style.alignItems = 'center';
targetElement.style.marginLeft = '10px';
console.log(`${LOG_PREFIX} 🎉 Link successfully added to header for ticket ${ticketId}!`);
processedTickets.set(ticketId, devopsLinkInput.value);
return true;
};
const checkCurrentTicket = () => {
console.log(`${LOG_PREFIX} checkCurrentTicket() called`);
const currentTicketId = getCurrentTicketId();
if (!currentTicketId) {
console.log(`${LOG_PREFIX} No ticket ID found in URL`);
return;
}
console.log(`${LOG_PREFIX} Current ticket ID from URL: ${currentTicketId}`);
addLinkToTicket(currentTicketId);
};
// Initial check when script loads
console.log(`${LOG_PREFIX} Running initial check...`);
setTimeout(() => {
checkCurrentTicket();
}, 1000);
// Watch for DOM changes (new tickets loading, fields appearing)
let mutationCount = 0;
const observer = new MutationObserver((mutations) => {
mutationCount++;
console.log(`${LOG_PREFIX} Mutation #${mutationCount} detected`);
// Check if any mutations added a ticket header or label
let shouldCheck = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1) { // Element node
if (node.matches && (node.matches('div[data-ticket-id]') || node.matches('label'))) {
shouldCheck = true;
break;
}
if (node.querySelector && (node.querySelector('div[data-ticket-id]') || node.querySelector('label'))) {
shouldCheck = true;
break;
}
}
}
}
}
if (shouldCheck) {
console.log(`${LOG_PREFIX} Relevant change detected, checking current ticket...`);
checkCurrentTicket();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
console.log(`${LOG_PREFIX} MutationObserver started, watching document.body`);
// Watch for URL changes (navigation between tickets)
let lastUrl = window.location.href;
const urlCheckInterval = setInterval(() => {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
console.log(`${LOG_PREFIX} URL changed from ${lastUrl} to ${currentUrl}`);
lastUrl = currentUrl;
// Give the page a moment to render the new ticket
setTimeout(() => {
checkCurrentTicket();
}, 500);
}
}, 500);
console.log(`${LOG_PREFIX} URL monitoring started`);
})();