Jira Blocker Lines: How CoPilot helped me fix Jira’s broken sprint boards.

Andrew C Young
Klaviyo Engineering
24 min readMay 1, 2024

--

The mail never stops.

Just want the blocker lines on your Jira sprint boards? [skip to the recipe]

The worst work tracking tool is the one you are using right now. I’ve used a whole range of solutions over the years: Pivotal Tracker, Target Process, Trello, Jira, even real life notecards on whiteboards. Whichever tool an organization is using to track its work, calling out some little annoyance is sure to be a regular part of every team’s culture. It could be the set of arcane button clicks you need to close out a sprint. Or maybe skipping a state in the progression breaks some dashboard. Does some misconfigured field keep causing your tickets to disappear, seemingly at random? Whatever it may be, teams are sure to bond over the common enemy of their work tracker.

There are a few reasons we love to hate these tools. For one, describing and tracking the work we do is simply less fun than actually doing the thing. The frustration with the necessarily bureaucratic side of our jobs makes the tools an easy target.

More than that though, it is the immense complexity of what we ask of them. We need these tools to bottle and repackage workflows at every level of granularity into a consistent language with views that fit the needs of just about every function in the organization.

  • Product Managers need to be able to design and visualize roadmaps for projects at both broad strokes long term and more detailed plans for broken down features to make sure we know what the right things are to build.
  • Engineering Managers need insights into cycle times, work estimates, and product priorities in order to properly allocate resources to ensure the right things get built.
  • Engineers need to be able to plan out and track the implementation details of features to make sure they build the thing right.
  • Designers need to understand the product requirements to design the right solution
  • Executives need to be able to build charts that are almost meaningful to hassle their reports with.
  • The list goes on.

At Klaviyo, we recently migrated to Jira from Target Process.

Jira does a whole bunch of stuff really well. The quality of the issue database. The searchability through a robust query language. The depth of customizations of the data and the views. The extensibility through diverse plugins and integrations. The robust web APIs. All together, Jira really is the gold standard for most use cases, and when properly configured, can usually solve the problem at hand better and with less effort than the competition.

One area where Jira falls short, however, is the sprint board.

To understand why, we need to understand what the core purpose of a sprint board really is.

Sprint boards give us a view into the current state of the work that a development team has committed to delivering. The view is used in team meetings (often the daily standup) as the backdrop to discussions where we give updates on the progress, figure out how to unblock work, and strategize about how work should be divided and ordered in the ways that will get things delivered efficiently. The board is also the place for individual developers to decide on what work makes the most sense for them to pick up next, and, if the work is tightly coupled to work in progress by other developers, who they should touch base with before getting started.

In order to accomplish those goals:

  1. A sprint board should offer an at a glance visualization of the current state of the work the team has committed to for the sprint. Jira does not make good use of screen real estate. This is especially true when using swimlanes. While you can easily filter by things like assignee, the best sprint boards allow for more of the sprint work to be in view at the same time, because that makes it easier to have the strategic conversations about how all the work in the sprint relates to each other.
  2. A sprint board should offer visual representation of blocked work. Jira allows you to configure all sorts of relationships to link issues, including blockers. This is great, except there is no indication of those relationships on the sprint board. You cannot know if an issue is blocked and what it is blocked by without clicking into the issue, and so you cannot have those critical strategy conversations, or even know what work is available to pick up from the sprint board view alone.

For a concrete example of why not having visuals for blockers is a problem, let’s take a look at this snippet of a sprint board.

On first glance, it looks like the task “Update the Grafana board” should be the next thing an engineer looking to pick up new work should grab.

tfw your real work is so generic it doesn’t even need to be blurred

It is only when we click into the task that we see all of the other tasks in flight for this story are blocking it, and if I was looking for work to do right now, I should instead be moving on to another story, or at the very least reaching out to my peers currently working on the blocking issues to make sure we aren’t stepping on each other’s toes.

Clicking into an issue to see the blockers

The sprint board is failing to communicate the most important information we need to make good decisions and collaborate effectively!

Wouldn’t it be much better if we could see those blocking relationships on the board itself without needing to drill in? What if instead the sprint board looked something like this:

Blocking relationships visualized on the sprint board.

Suddenly the strategic relationships between the tasks are front and center. That last card in the To Do column is blocked, and it is perfectly clear what needs to be done before it is ready to go. This makes group conversations at standup more effective and makes it easier for individuals to make the right decision about what work to do next throughout the day.

Conversations with CoPilot

I’ve used Jira before Klaviyo, and for me, this pain point (the lack of any clear visualization of blockers) has frequently been that little annoyance that stands out in the tool. “Will you click into that issue so we can see the blockers?” has been an almost daily refrain for our team.

And it’s not just us. People have been calling for this critical feature for a full decade at this point.

Martin — Inactive, but not forgotten

There’s no indication that Atlassian is ever going to move on this. The idea seems to be that it should be solvable with some mix of JQL and plugins. From what I have seen, however, no one has ever been able to implement this in a way that actually solves the problem (A random list of gray Issue keys on a card does not count when you still need to click in for them to mean anything). What should be a table stakes requirement remains frustratingly out of reach.

Once, I found a Chrome Extension that injected some blockers indicators, but it had been broken for years before I first stumbled across it, which was now years ago. If I ever thought about trying to solve the problem myself, it always seemed likely to be just a little bit too much work to be worth it when the solution is guaranteed to eventually fail as Jira rolls out layout updates.

Before Klaviyo’s migration to Jira, my team had grown accustomed to making extensive use of blocker relationships and the blocker line visualizations that Target Process offers. Our team’s process is rooted in breaking down the user stories we are presented with into small tasks as a group and swarming on them wherever possible. Having such a tightly collaborative process, our team has felt the pain of Jira’s lack of blocker visualization on sprint boards acutely. Needing to click into an issue to see blockers is not just an inconvenience, it makes it fundamentally harder to execute.

Another tool we recently rolled out at Klaviyo is Github CoPilot.

Seeing what generative AI can do, I started to wonder if it could tilt the balance of the equation. If I work together with the robots, can we get something good enough to solve the pain without taking too much time away from the more immediately pressing work on my desk?

To start, I tried just asking for what I wanted.

Draw the rest of the owl

The answer I got was effectively that I should go and do a whole bunch of learning and work. Things I could do with enough time, but it would be a whole lot more time than I actually want to spend on this problem.

I knew my prompt was a big ask, but at least I got a good skeleton to get me started out of it.

Or so I thought.

When I followed the instructions CoPilot had given me to load the extension code into Chrome, I was blasted with errors.

You can no longer add new extensions where manifest_version is 2.

I told CoPilot about the errors and we eventually prompted our way to a functional skeleton of a valid version 3 Chrome Extension. The process was surprisingly slow and painful and I was left wishing I had just kicked things off by copying the ”Hello World!” template from the Chrome developer pages.

At this point, not only is CoPilot not speeding things up, it is actively slowing me down!

Now that I had a working skeleton for the extension, I figured the rest of problem could be broken down into 3 main threads:

  1. Scraping the page to get all the issue cards.
  2. Using the API to query for blocker relationships.
  3. Drawing the blockers to the page.

First I wanted to take on the HTML scraping side of the problem. I used the Chrome Inspector to copy the DOM nodes for an issue card on a Jira sprint board and asked CoPilot to script scraping matching elements from the page and mapping those elements by issue key. I noticed Jira uses auto generated classes with random names, so I included a request to not use classes for the matching in my prompt.

While the response ignored my direct request not to use classes for pattern matching, running the function in the console got me exactly the mapping I wanted, and it didn’t take many prompted tweaks to get it pattern matching on things other than those dynamically generated class names.

Getting to a solid working solution for scraping and mapping elements in 5 minutes means I am already starting to tip the balance of effort to value and save some time.

Now that I had a map of issues to DOM elements, I needed to get the blocker relationships from the API. I just grabbed the URL of an API request Jira was making out of the Chrome network tab. I didn’t look into the web response and I certainly didn’t load any documentation. I just asked CoPilot to use the endpoint I had found.

Because the Chrome extension is running on a page with Jira loaded, I try just removing the Authorization header and crossing my fingers, hoping the session auth would automagic, and I wouldn’t need to go down that rabbit hole.

Success!

It is making API requests and I can now get the links for issues on the page.

I start putting it all together into the extension, asking CoPilot to make it update based on the cards on the page in a regular interval, and it just works.

I now had an extension that was scraping issues of the page by detecting issue cards on the Sprint Board and loading data about them from the Jira API, an API I have never worked with, and all without needing to look at any documentation.

About 45 minutes in, 2 of the 3 steps I layed out are done, and I can add a few more hours to the ledger of time saved.

In order to draw the lines, I figure it would be convenient to have a nice function to map the blocker relationships from the API response.

The code CoPilot produced here doesn’t work.

It was erroring on an undefined key. I pointed out the error, pasting the error directly into CoPilot and got an oddly defensive response, insisting the issue was most likely customizations on our Jira instance, or maybe an authorization issue, because CoPilot claimed the code produced should work fine with the Jira API.

After a little time debugging and digging into the web response payload in the Chrome Network panel, I realized CoPilot had just missed a layer when drilling into the API data. An easy fix, but certainly one that benefited from my own experience and CoPilot’s obstinance here could easily have slowed things down more than it did.

We have a map of issues to HTML elements. We also have a map of their blockers.

Time to start drawing some lines.

I pasted the code in as is, and was a little rattled to see the lines show up in the right places. The power of the visual effect combined with seeing CoPilot shine in the area where I would likely have struggled most, and this was the first moment where I have been well and truly humbled by this technology.

A little over an hour since my first prompt, and blocker lines are showing on my sprint board.

What am I going to complain about in stand ups now?

To be clear, things are not actually usable at this point. There’s a million little things wrong, and a few pretty big things wrong. This was made painfully obvious when I scrolled the page and the lines stayed put. Even still, just getting those lines on the page would have taken hours of googling and fiddling. I am full of hope and have a solid foundation to prompt against with CoPilot until I have something valuable.

Starting with those sticky lines, I popped open the Chrome Inspector and figured out which element was the scroll container, I asked it to update on scrolling instead of on timer.

And now the whole page just crashes the moment you scroll. Need a little debouncing.

Feeling fancy, maybe some flourish?

The lines are a little off the intended mark.

I found CoPilot is less defensive if you don’t call out the mistakes directly. Guiding questions get things sorted faster.

For small asks, CoPilot works well with a casual tone, at times feeling like a very organic peer programming experience. I did find that the more complex the ask gets, the more formal and exact the language in the prompt should be. Though this is really just as true when collaborating with my human peers.

For every prompt, I just copy what is offered, paste it directly into the code, and debug it by hand if it blows things up. A few dozen prompts later, it was already something worth having.

Everything came together so quickly that I became more ambitious, and having a proof of concept in hand helped me to better understand what an ideal solution would look like.

For one, the shortcomings in Jira’s use of screen real estate meant that even if both issues in a blocker relationship are in the sprint, they are rarely both in view; making the purely line based visualization I had started with less useful in communicating the relationship.

I thought it would be nice to use distinct icons on issue cards for different link relationships. I eventually settled on an x to indicate an issue is blocked, and a circle to indicate that the issue blocks other issues. If both issues are in view, then the icons would be connected by lines, but the icons would still show if only one issue in the relationship is in view. Ideally you could hover over the icons to see a list of the related issues.

Again things are in a functional state super quickly. A brief casual chat with CoPilot later, and we have something really cool. It is using the API to get data about all the linked issues and presenting the linked issues in the popup with the names, status, and even the assignee avatars in a pleasantly styled popup.

The pop ups are my favorite feature, and I had not even imagined them when I got started.

I kept iterating, asking for new features and improvements, then pasting the results. Adding a checkbox in the top bar to toggle the blockers on and off, making icons green when all the blockers are done, working with swimlanes. All of it just inserted as CoPilot provided and debugged as needed. I intentionally chose not to make any significant changes by hand, letting CoPilot be the primary author of all the code.

Initially the mistakes that CoPilot made were fairly simple to fix. It consistently missed required arguments in some built-in JS functions. It made errors around key paths when drilling into objects. It frequently opened bracketed code blocks it had no intention of closing. Honestly, many of these mistakes and little errors were the sort I might have made myself on a first pass, and that’s what made them so easy to debug.

Things started to get harder the more complex the code got. The snippets got less likely to work and needed to be pasted into more places. I asked CoPilot for help DRYing the code and making other structural improvements. It was helpful, but I found most of its attempts to refactor larger chunks much more clunky and harder to implement, and took many more rounds of prompting and much more manual debugging to make things work than it had when asking for small changes and net new development. If 25% of my time went to talking to CoPilot, 75% went into debugging the issues in the results, and the resulting code quality was still fairly poor. Still, the whole project was usable in hours instead of the days it would have taken me without the help. It made solving the business need “in house” practical, and the tool is now a regular part of our team’s daily workflow.

The slow down in progress that came with the added complexity was not surprising, but it was a nice small-scale example of where generative AI is going to be the biggest force multiplier in the years ahead.

Net new work and changes to clearly delineated code spaces have always been faster than changes in complex interconnected code bases, and the difference is going to grow significantly with generative assistants. Organizations that have prioritized strong boundaries between discrete and relatively simple services with comprehensive testing are going to see the biggest efficiency gains. Bad patterns are in danger of being amplified and cannibalizing the potential gains. The same principles of good design, making it easier for humans to understand and work in a code base, are going to be the design principles that make it easier for the robots to be positive force multipliers. If I had focused on maintaining quality from the start, I would have been able to have a more robust solution and CoPilot would have maintained its velocity.

There is also going to be a larger gain in the productivity of experienced engineers. Debugging and code review are the learned skills that are going to matter most in leveraging robot assistants capable of writing a lot of code quickly. Knowing how to take the results and massage them into quality code is going to be critical in preventing that vast output from turning your codebase into an unmanageable monster of spaghetti that neither robots nor humans can effectively engage with.

The robots are not ready to take our jobs, but we need to be ready to get the most out of them. Practice prompting, lean into code quality, follow the best practices. The guiding principles are the same as they have been, but are going to be more important than ever in accelerating productivity.

And now that we have blocker lines on our sprint boards, we can finally collaborate effectively to get ready for them.

Jira Blockers Full Recipe

Disclaimer

This extension is buggy. The code is at times very confusing. I did not clean up after the robots. I left empty files the chatbot included. This code WILL break as Jira rolls out updates. I WILL NOT maintain it. I am not publishing this to the chrome store because it is not work I would ever put my name on or sell, yet I still find it amazingly useful and want to share with others who have felt the pain of Jira’s shortcomings. If you fix or improve the code please share the love in the comments.

Instructions:

  1. Put all of these files into a single folder.
  2. Go to chrome://extensions/.
  3. At the top right, turn on Developer mode.
  4. Click Load unpacked.
  5. Find and select the app or extension folder.

content.js


function mapIssueIdsToElements(accumulator = {cardElements: {}, iconElements: {}, issueKeyElements: {}}) {
const elements = document.querySelectorAll('div[data-testid*="card-container"], div[data-testid*="swimlane.swimlane-content"]');
return Array.from(elements).reduce((acc, element) => {
const subTaskElement = element.querySelector('img[src*="rest/api/2/universal_avatar/view/type/issuetype/avatar/"]');
if (!subTaskElement) return acc;
const issueIdElement = subTaskElement.parentElement.parentElement.parentElement.querySelector('a[target="_blank"] > span');
if (subTaskElement && issueIdElement) {
const issueId = issueIdElement.textContent;
acc.iconElements[issueId] = subTaskElement;
acc.cardElements[issueId] = element;
acc.issueKeyElements[issueId] = issueIdElement;
}
return acc;
}, accumulator);
}
let elements = mapIssueIdsToElements();
let cache = {};
let seenIssues = new Set();

let blockedIssuesMap = {};
let blocksIssuesMap = {};
let blockedIconMap = {};
let blocksIconMap = {};
let cardObservers = [];
let everythingElseElement;
let blockedByUncompletedMap = {};
let statusMap = {};


// Function to fetch data for an issue
function fetchDataForIssues(issueKeys) {
if (issueKeys.length === 0) return Promise.resolve({issues: []});
let issueKeysString = issueKeys.join(',');
let jqlQuery = 'key in (' + issueKeysString + ')';
let url = 'https://{INSERT_YOUR_SUBDOMAIN}.atlassian.net/rest/api/2/search';
let headers = {
'Content-Type': 'application/json',
};
let body = JSON.stringify({
jql: jqlQuery,
fields: ['issuelinks', 'status']
});

return fetch(url, {
method: 'POST',
headers: headers,
body: body
})
.then(response => response.json())
.then(data => {
return data;
});
}

function mapBlockedIssues(data) {
const filteredData = data.issues.filter(issue => issue.fields.issuelinks.length > 0);
filteredData.forEach(issueData => {

let issueKey = issueData.key;
let blocksIssues = [];
let blockedIssues = [];
let blockedByUncompletedIssues = [];

issueData.fields.issuelinks.forEach(link => {
if (link.type.name === 'Blocks' && link.outwardIssue) {
if (blocksIssues.includes(link.outwardIssue.key)) return;
blocksIssues.push(link.outwardIssue.key);
}
if (link.type.name === 'Blocks' && link.inwardIssue) {
if (blockedIssues.includes(link.inwardIssue.key)) return;
if (link.inwardIssue.fields.status.name !== 'Done') {
blockedByUncompletedIssues.push(link.inwardIssue.key);
}
blockedIssues.push(link.inwardIssue.key);
}
});
statusMap[issueKey] = issueData.fields.status.name;
if (!blockedIssuesMap[issueKey] && blockedIssues.length > 0) {
blockedIssuesMap[issueKey] = blockedIssues;
}
else if (blockedIssues.length > 0){
blockedIssuesMap[issueKey] = [...new Set([...blockedIssuesMap[issueKey], ...blockedIssues])];
}
if (!blocksIssuesMap[issueKey] && blocksIssues.length > 0) {
blocksIssuesMap[issueKey] = blocksIssues;
}
else if (blocksIssues.length > 0){
blocksIssuesMap[issueKey] = [...new Set([...blocksIssuesMap[issueKey], ...blocksIssues])];
}
if (!blockedByUncompletedMap[issueKey] && blockedByUncompletedIssues.length > 0) {
blockedByUncompletedMap[issueKey] = blockedByUncompletedIssues;
} else if (blockedByUncompletedIssues.length > 0) {
blockedByUncompletedMap[issueKey] = [...new Set([...blockedByUncompletedMap[issueKey], ...blockedByUncompletedIssues])];
}
});
}

function createElement(type, properties = {}) {
let element = document.createElement(type);

for (let property in properties) {
if (property === 'style') {
for (let style in properties[property]) {
element.style[style] = properties[property][style];
}
} else if (property === 'text') {
element.textContent = properties[property];
} else {
element[property] = properties[property];
}
}

return element;
}

function addIssueRowToPopup(issue, link) {
// Create elements for the name, status, and assignee
let name = createElement('h5', {
text: issue.fields.summary,
style: { margin: '0' }
});

let issueKeyElement = createElement('p', {
text: issue.key,
style: { margin: '0' }
});

let status = createElement('span', {
text: issue.fields.status.name,
style: {
backgroundColor: issue.fields.status.statusCategory.colorName,
color: 'black',
padding: '2px 5px',
borderRadius: '2px',
marginLeft: '10px'
}
});

let assignee = issue.fields.assignee ? createElement('img', {
src: issue.fields.assignee.avatarUrls['48x48'],
alt: issue.fields.assignee.displayName,
title: issue.fields.assignee.displayName, // Add hover text
style: {
width: '20px',
height: '20px',
borderRadius: '50%',
marginLeft: '10px'
}
}) : null;

let container = createElement('div', {
style: {
borderBottom: '1px solid #ccc',
padding: '10px'
}
});

container.appendChild(name);

let infoContainer = createElement('div', {
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}
});

infoContainer.appendChild(issueKeyElement);
infoContainer.appendChild(status);
if (assignee) {
infoContainer.appendChild(assignee);
}

container.appendChild(infoContainer);

link.appendChild(container);

return link;
}

function generatePopup(header, relations, anchor) {

let existingPopup = document.querySelector('.blocker-popup');
if (existingPopup) {existingPopup.remove(); return;}

let popup = document.createElement('div');

popup.style.position = 'fixed';
popup.style.left = `${anchor.getBoundingClientRect().left - 70}px`;
popup.style.top = `${anchor.getBoundingClientRect().top - 175}px`;
popup.style.backgroundColor = '#f9f9f9';
popup.style.border = '1px solid #ccc';
popup.style.padding = '10px';
popup.style.width = '220px';
popup.style.height = '150px';
popup.style.overflowY = 'auto';
popup.style.borderRadius = '4px';
popup.style.boxShadow = '0 2px 5px rgba(0,0,0,0.15)';
popup.classList = 'blocker-popup';

let header1 = document.createElement('h2');
header1.textContent = header;
header1.style.marginTop = '0';
header1.style.position = 'sticky'; // Make the header sticky
header1.style.top = '0'; // Stick the header to the top of the popup
header1.style.backgroundColor = '#f9f9f9'; // Give the header the same background color as the popup
header1.style.boxShadow = '0 2px 5px rgba(0,0,0,0.15)'; // Add a drop shadow to the header
header1.style.zIndex = '1'; // Ensure the header is above the scroll content
header1.style.padding = '10px'; // Add some padding to the header

popup.appendChild(header1);

// Create a spinner
let spinner = document.createElement('div');
spinner.style.border = '16px solid #f3f3f3';
spinner.style.borderTop = '16px solid #3498db';
spinner.style.borderRadius = '50%';
spinner.style.width = '120px';
spinner.style.height = '120px';
spinner.style.animation = 'spin 2s linear infinite';
spinner.style.margin = '20px auto';
popup.appendChild(spinner);

let promises = relations.map(relatedIssue => {
let link = document.createElement('a');
let url = new URL(window.location.href);
let params = new URLSearchParams(url.search);
params.set('selectedIssue', relatedIssue);
url.search = params.toString();
link.href = url.toString();
link.style.display = 'block';
link.style.marginBottom = '5px';

// Fetch the issue data from the Jira API
return fetch(`https://{INSERT_YOUR_SUBDOMAIN}.atlassian.net/rest/api/2/issue/${relatedIssue}`, {
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(issue => {
return addIssueRowToPopup(issue, link);
})
.catch(error => console.error('Error:', error));
});

Promise.all(promises)
.then(links => {
// Remove the spinner
spinner.remove();

// Append the links to the popup
for (let link of links) {
popup.appendChild(link);
}
});

// Add the CSS for the spinner animation
let style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);

return popup;
}

function drawBlockerLines() {

// Iterate through all relationships in the blocked issues map
for (let issueKey in blockedIssuesMap) {

if (!elements.iconElements[issueKey]) continue;

let fromElement = elements.issueKeyElements[issueKey];
let fromRect = fromElement.getBoundingClientRect();
let fromX = fromRect.left + fromRect.width / 2 + 7;
let fromY = fromRect.bottom + 5;
if (!blockedIconMap[issueKey]) {
const issueKeyElement = elements.issueKeyElements[issueKey];
// Create a new SVG element for the X
let svgX = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgX.style.position = 'absolute';
svgX.style.left = `${fromX - 5}px`; // 5px gap between the icon and the X
svgX.style.top = `${fromY - 5 }px`;
svgX.style.width = '10px';
svgX.style.height = '10px';

// Create a path for the X
let pathX = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pathX.setAttribute('d', 'M 0 0 L 10 10 M 10 0 L 0 10');
pathX.style.stroke = blockedByUncompletedMap[issueKey] ? 'red' : 'green';
pathX.style.strokeWidth = '4';

pathX.classList.add('blocker-icon');

// Append the path to the SVG element
svgX.appendChild(pathX);
svgX.classList.add('blocker-icon');
document.body.appendChild(svgX);
blockedIconMap[issueKey] = svgX;

svgX.addEventListener('click', (event) => {
// Prevent the document click event listener from firing
event.stopPropagation();

// Remove any existing popups
const popup = generatePopup('Blocked By', blockedIssuesMap[issueKey], svgX);
document.body.appendChild(popup);
});
}

let blockedIssues = blockedIssuesMap[issueKey];

blockedIssues.forEach(blockedIssueKey => {
if (!elements.issueKeyElements[blockedIssueKey]) return;

const toElement = elements.issueKeyElements[blockedIssueKey];
let toRect = toElement.getBoundingClientRect();
let toX = toRect.left + toRect.width / 2 - 7;
let toY = toRect.bottom + 5;
if (!blocksIconMap[blockedIssueKey]) {
// Create a new SVG element for the circle
let svgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgCircle.style.position = 'absolute';
svgCircle.style.left = `${toX - 5}px`; // 5px gap between the icon and the circle
svgCircle.style.top = `${toY - 5}px`;
svgCircle.style.width = '10px';
svgCircle.style.height = '10px';

// Create a circle for the SVG
let circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '5');
circle.setAttribute('cy', '5');
circle.setAttribute('r', '5');
circle.style.fill = statusMap[blockedIssueKey] === 'Done' ? 'green' : 'red';
circle.classList.add('blocker-icon');

// Append the circle to the SVG element
svgCircle.appendChild(circle);
svgCircle.addEventListener('click', (event) => {
// Prevent the document click event listener from firing
event.stopPropagation();

// Remove any existing popups
let existingPopup = document.querySelector('.blocker-popup');
if (existingPopup) { existingPopup.remove(); return; }

// Create a popup with links to the related issues
const popup = generatePopup('Blocks', blocksIssuesMap[blockedIssueKey], svgCircle);
document.body.appendChild(popup);
});

blocksIconMap[blockedIssueKey] = svgCircle;
document.body.appendChild(svgCircle);
}

if (fromElement && toElement) {
// Create SVG container
let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.position = 'absolute';
svg.style.top = '0';
svg.style.left = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
svg.style.opacity = '0.5';
// Calculate positions
let markerLength = 10;
if([fromX, fromY, toX, toY].some(value => value === undefined || value === null || value === NaN || value === Infinity || value === -Infinity || value === '' || value === 0 || value < 100)) {
return;
}
// Calculate mid-point
let midX = (fromX + toX) / 2;
let midY = (fromY + toY) / 2;

// Create SVG path
let line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let d = `M ${fromX} ${fromY}
Q ${midX - 50} ${midY - 40} ${toX} ${toY}`;
line.setAttribute('d', d);
line.style.stroke = (!blockedByUncompletedMap[issueKey] || !blockedByUncompletedMap[issueKey].includes[blockedIssueKey]) && statusMap[issueKey] === 'Done' ? 'green' : 'red';
line.style.strokeWidth = '2';
line.style.fill = 'none';
line.style.opacity = '0.5';
svg.appendChild(line);

const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M0,0 L0,6 L9,3 z');

line.setAttribute('marker-end', 'url(#arrow)');
line.classList.add('blocker-line');
svg.classList.add('blocker-line');

// Append line to SVG container
svg.appendChild(line);

// Append SVG container to body
document.body.appendChild(svg);
}
});
}
}

// Call the function for each issue key in the blocked issues map
function clearBlockers() {
let existingIcons = document.querySelectorAll('.blocker-icon');
existingIcons.forEach(icon => icon.remove());
let existingPopups = document.querySelectorAll('.blocker-popup');
existingPopups.forEach(popup => popup.remove());
let existingLines = document.querySelectorAll('.blocker-line');
existingLines.forEach(line => line.remove());
blockedIconMap = {};
blocksIconMap = {};
}

// Function to fetch data for all issues
async function fetchDataForAllIssues() {
elements = mapIssueIdsToElements(elements);
let issueKeys = Object.keys(elements.cardElements);
let unseenIssues = issueKeys.filter(issueKey => !seenIssues.has(issueKey));
seenIssues = new Set([...seenIssues, ...unseenIssues]);
try {
const data = await fetchDataForIssues(unseenIssues);
mapBlockedIssues(data);
await clearBlockers();
await drawBlockerLines();
unseenIssues.forEach(issueKey => {
// Select the card element
let cardElement = elements.cardElements[issueKey];
if (!cardElement) return;

// Check if the data-testid attribute contains 'swim'
let testId = cardElement.getAttribute('data-testid');
if (!testId || !testId.includes('swim')) return;

// Create a MutationObserver to listen for changes to the 'aria-expanded' attribute
let observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-expanded') {
clearBlockers();
setTimeout(() => fetchDataForAllIssues(), 100);
}
});
});

// Start observing the card element for attribute changes
observer.observe(cardElement, { attributes: true });
cardObservers.push(observer);
});
if (!everythingElseElement) {
everythingElseElement = Array.from(document.querySelectorAll('[data-testid*="swimlane"][aria-expanded]')).find(el => Array.from(el.querySelectorAll('*')).some(child => child.textContent.includes('Everything else')));
if (everythingElseElement) {
// Create a MutationObserver to listen for changes to the 'aria-expanded' attribute
let observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-expanded') {
clearBlockers();
setTimeout(() => fetchDataForAllIssues(), 100);
}
});
});

// Start observing the everythingElseElement for attribute changes
observer.observe(everythingElseElement, { attributes: true });
cardObservers.push(observer);
}
}
} catch (error) {
console.error('Error:', error);
}
}


// Grab the scroll container
setTimeout(() => {
// Create a checkbox
let checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'blockers-toggle';
// Check the checkbox if the value in localStorage is 'true'
checkbox.checked = localStorage.getItem('blockers-toggle') === 'true';

// Create a label for the checkbox
let label = document.createElement('label');
label.htmlFor = 'blockers-toggle';
label.textContent = 'Show Blockers';
label.style.color = '#000'; // Black text
label.style.fontWeight = '500'; // Make the text less bold

// Create a wrapper div
let wrapper = document.createElement('div');
wrapper.style.display = 'inline-flex'; // Center the checkbox and label
wrapper.style.alignItems = 'center'; // Vertically center
wrapper.style.justifyContent = 'center'; // Horizontally center
wrapper.style.backgroundColor = '#ffcccc'; // Light red background
wrapper.style.padding = '5px'; // Some padding
wrapper.style.borderRadius = '3px'; // Make the corners even less rounded
wrapper.style.boxShadow = '0px 3px 6px rgba(0, 0, 0, 0.16)'; // Drop shadow

let outerWrapper = document.createElement('div');
outerWrapper.style.display = 'flex'; // Use flexbox
outerWrapper.style.justifyContent = 'center'; // Center horizontally
outerWrapper.style.alignItems = 'center'; // Center vertically
// Append the wrapper to the outer wrapper
outerWrapper.appendChild(wrapper);

// Append the checkbox and label to the wrapper
wrapper.appendChild(checkbox);
wrapper.appendChild(label);
// Append the wrapper to the parent of the element with the test id "create-button-wrapper"
let createButtonWrapper = document.querySelector('[data-testid="create-button-wrapper"]');
createButtonWrapper.parentNode.insertBefore(outerWrapper, null);

// Only clear and fetch blockers if the checkbox is checked
if (checkbox.checked) {
clearBlockers();
elements = mapIssueIdsToElements(elements);
fetchDataForAllIssues();
}

// Listen for changes to the checkbox
checkbox.addEventListener('change', async () => {
await clearBlockers();
// Store the checkbox value in localStorage
localStorage.setItem('blockers-toggle', checkbox.checked.toString());
if (checkbox.checked) {
// If the checkbox is checked, draw the lines
elements = mapIssueIdsToElements(elements);
await fetchDataForAllIssues();
await drawBlockerLines();
} else {
// If the checkbox is unchecked, clear the lines
await clearBlockers();
}
});

let scrollContainer = document.querySelector('[data-testid*=".board-scroll"]');
let timeoutId = null;

scrollContainer.addEventListener('scroll', () => {
clearBlockers();
// Only clear and fetch blockers if the radio button is checked
if (checkbox.checked) {
// Clear all SVG elements when the scroll starts

// Cancel the previous timeout if it exists
if (timeoutId) {
clearTimeout(timeoutId);
}

// Set a timeout to execute the functions after the scroll has stopped for a quarter second
timeoutId = setTimeout(() => {
clearBlockers();
fetchDataForAllIssues();
}, 100); // 250 milliseconds = a quarter second
}
});

// Clear all SVG elements when the URL changes
window.onpopstate = () => {
clearBlockers();
// Only clear and fetch blockers if the radio button is checked
if (checkbox.checked) {
fetchDataForAllIssues();
}
};
}, 1000);

// Add a click event listener to the document to close the popup when clicking away
document.addEventListener('click', () => {
let existingPopup = document.querySelector('.blocker-popup');
if (existingPopup) existingPopup.remove();
});

// Add the CSS for the spinner animation
let style = document.createElement('style');
style.textContent = `
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;

background.js

console.log('background worker logic should go here');

manifest.json

{
"manifest_version": 3,
"name": "Jira Blocker Lines",
"version": "1.0",
"permissions": ["activeTab", "scripting"],
"background": {
"service_worker": "background.js"
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png" },

"content_scripts": [
{
"js": ["content.js"],
"matches": [
"https://{INSERT_YOUR_SUBDOMAIN}.atlassian.net/jira/software/c/projects/CHNL/boards/*"
]
}
],
"host_permissions": [
"https://{INSERT_YOUR_SUBDOMAIN}.atlassian.net/jira/*"
]
}

icon16.png

icon48.png

icon128.png

--

--