Implementing Poker Planning in YouTrack Using Workflows

Introduction
As a QA Engineer primarily working with YouTrack, I often find myself exploring ways to streamline the planning and estimation process for my team. One of the most widely used Agile techniques is Planning Poker, a consensus-based estimation method that fosters team cooperation while estimating task complexities.Unfortunately, YouTrack doesn’t have in-built functionality for Planning Poker, which was a significant limitation for our Agile workflow. When faced with this issue, I had two options: use an external tool alongside YouTrack or develop a custom solution within YouTrack itself.
Rather than switching back and forth between tools, I chose to implement Planning Poker directly in YouTrack using its workflow scripting API.
This blog post details how I developed this solution, including the creation of custom fields, workflows for vote collection, automatic calculation of story points, posting results transparently in comments, and updating the story points field.
The Goal
My goal was to create a seamless Poker Planning "feature" within YouTrack that achieves the following:
- Allows team members to vote on effort estimates using a custom voting field.
- Records each vote and provides an option to update it privately.
- Automates story point calculation based on the Fibonacci sequence.
- Publishes voting data in comments for transparency, collaboration and just for the record.
- Updates the issue’s Story points field with the final estimation.
To achieve this, I created:
1. Custom fields in YouTrack to store votes, metadata, and final story points.
2. Three YouTrack workflows using its JavaScript-based API.
Adding Custom Fields to a Project
Before creating workflows, we need to set up relevant fields in YouTrack to store data.
1. Navigate to Project Settings > Fields in the relevant project.
2. Add these three fields ("Poker estimation", "Poker data", "Story points") to the project:
- Poker estimation (Enum Field)
This field temporarily stores individual votes submitted by team members before processing them in the workflow.
- Set the field type to Enum and name it "Poker estimation".
- Add Fibonacci numbers often used for estimation (e.g., '1', '2', '3', '5', '8', '13', '21').
- Ensure this field is visible and editable to all team members for voting.
- Poker data (String Field)
This field stores the collected votes and metadata as JSON for use in the workflows.
- Create a string field named "Poker data".
- Keep it hidden from regular users to prevent accidental edits.
- Story points (Integer Field)
This field will store the calculated final story points based on team votes.
- Set the field type to Integer and name it "Story points".
With these fields in place, we're ready to dive into the workflows.
Workflow 1: Collecting Poker Votes
The first workflow captures individual votes when a user selects an option in the "Poker estimation" field. It records the vote in the "Poker data" field and clears the estimation field so that subsequent votes remain private.
Code Explanation:
When a user updates the "Poker estimation" field, this workflow:
- Reads the vote and resets the field immediately.
- Updates the vote in the "Poker data" field as JSON, storing each user’s vote.
- Allows users to update their votes if needed.
var entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
var FIELD_POKER_VOTE = "Poker estimation"; // Field for voting (enum)
var FIELD_POKER_DATA = "Poker data"; // Field to store voting data (string)
exports.rule = entities.Issue.onChange({
title: 'Poker planning',
guard: function(ctx) {
// Process only when the Poker estimation field is updated
return ctx.issue.fields.isChanged(FIELD_POKER_VOTE);
},
action: function(ctx) {
var issue = ctx.issue;
try {
// === Read the Current Vote ===
var currentVote = issue.fields[FIELD_POKER_VOTE];
// === Clear the Poker estimation field immediately ===
issue.fields[FIELD_POKER_VOTE] = null; // Reset the field for reuse
// === Read and Update Poker Planning Data ===
var pokerDataJSON = issue.fields[FIELD_POKER_DATA]; // Get existing voting data
var pokerData = {};
try {
// Parse the JSON or initialize it if no data is present
pokerData = pokerDataJSON ? JSON.parse(pokerDataJSON) : { voteResult: [] };
} catch (jsonError) {
console.error("Error parsing Poker planning data JSON:", jsonError.message);
pokerData = { voteResult: [] }; // Fallback to an empty structure
}
// === Get Current User Information ===
var user = ctx.currentUser; // Get the user who performed the action
if (!user || !user.login) {
console.error("Error: Current user is unavailable or login is missing.");
return; // Exit if user info is invalid
}
// === Update the User's Vote in Poker Data ===
var userVote = currentVote ? currentVote.name : null; // Read the enum option name (null if vote is removed)
var existingVote = pokerData.voteResult.find(v => v.login === user.login); // Check if the user has already voted
if (existingVote) {
// Update the existing vote
workflow.message(`Your vote is ${userVote}. Thank you!`);
existingVote.vote = userVote;
} else {
// Add a new vote entry
console.log(`Adding new vote entry for user ${user.login}:`, userVote);
pokerData.voteResult.push({
login: user.login,
vote: userVote
});
}
// === Save the Updated Poker Planning Data ===
issue.fields[FIELD_POKER_DATA] = JSON.stringify(pokerData); // Save updated data to the field
} catch (error) {
// Log comprehensive error details for debugging
console.error("Error processing Poker planning:", error);
if (error) {
console.error("Error Type:", typeof error);
console.error("Error Details:", JSON.stringify(error, null, 2));
console.error("Error Message:", error.message || "No error message available");
console.error("Error Stack:", error.stack || "No stack trace available");
} else {
console.error("Unknown error encountered.");
}
}
},
requirements: {
PokerVote: {
type: entities.EnumField.fieldType,
name: FIELD_POKER_VOTE,
multi: false
},
PokerData: {
type: entities.Field.stringType,
name: FIELD_POKER_DATA
}
}
});
Workflow 2: Calculating and Posting Story Points
Once all votes are collected, this workflow calculates the average vote, rounds it up to the nearest Fibonacci number, and automatically updates the "Story points" field.
Code Explanation:
1. Run a command (e.g., `/wrs-set-sp`) to trigger this workflow. This command is also available in the “More” menu next to the issue’s summary, allowing users to trigger the workflow with a button press.
2. The workflow retrieves votes from the "Poker data" field and processes them.
3. It calculates the average and rounds it to the nearest Fibonacci number.
4. Publishing the votes and results in an issue comment, updating the table with results if it already exists.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
const reqs = require('youtrack-scripting-api-extended/requirements');
exports.rule = entities.Issue.action({
title: 'Post or Update Poker Votes',
command: 'wrs-post-votes',
guard: (ctx) => {
return ctx.currentUser.isInGroup('your-team-group'); // Restrict command to a group
},
action: (ctx) => {
const issue = ctx.issue;
// Capture the original Poker data
const pokerDataJSON = issue.fields['Poker data'];
if (!pokerDataJSON) {
workflow.message('Poker data is empty!');
return;
}
try {
// Deserialize and clone the data
const pokerData = JSON.parse(pokerDataJSON);
// Validate the voteResult structure
if (!pokerData.voteResult || !Array.isArray(pokerData.voteResult)) {
console.log('Invalid pokerData.voteResult format:', pokerData.voteResult);
workflow.message('No valid vote results found in Poker data.');
return;
}
// Process current votes
const currentVotes = {};
pokerData.voteResult.forEach(entry => {
if (entry && entry.login && entry.vote) {
currentVotes[entry.login] = parseFloat(entry.vote.toString().trim());
}
});
// Parse the existing comment for previous votes
const existingComment = issue.comments.find(comment =>
comment.text.startsWith('***This comment has limited visibility:***\n**Poker Votes:**')
);
let previousVotes = {};
if (existingComment) {
const voteLines = existingComment.text.split('\n').slice(3); // Skip header
voteLines.forEach(line => {
if (!line.trim() || line.includes('User') || line.includes('---------------')) return;
const match = line.match(/^\|\s*(\S+?)\s*\|\s*(.+?)\s*\|$/);
if (match) {
const [_, login, voteField] = match;
previousVotes[login.trim()] = voteField.trim(); // Save parsed votes
}
});
}
// Build the new votes table
const votesArray = Object.keys(currentVotes).map(login => {
const currentVote = currentVotes[login]?.toString();
const previousVoteField = previousVotes[login] || ""; // Get the previous vote field, default empty
let voteField;
if (previousVoteField) {
const cleanPreviousField = previousVoteField.trim();
const lastVote = cleanPreviousField.split('→').pop().trim(); // Get last recorded vote
if (!lastVote || lastVote !== currentVote) {
voteField = cleanPreviousField ? `${cleanPreviousField} → ${currentVote}` : currentVote;
} else {
voteField = cleanPreviousField;
}
} else {
voteField = currentVote;
}
const lastNumericVote = parseFloat(voteField.split('→').pop().trim()) || 0;
return {
login,
voteField,
lastNumericVote
};
});
// Sort rows by 'lastNumericVote' (ascending order)
votesArray.sort((a, b) => a.lastNumericVote - b.lastNumericVote);
// Extract all numeric votes for calculations
const numericVotes = votesArray.map(({ lastNumericVote }) => lastNumericVote).filter(v => !isNaN(v));
// Calculate Average
const average = (numericVotes.reduce((sum, num) => sum + num, 0) / numericVotes.length || 0).toFixed(2);
// Calculate the Median
const median = numericVotes.length ? calculateMedian(numericVotes).toFixed(2) : 'N/A';
// Calculate Nearest Fibonacci number upwards
const nearestFibonacci = numericVotes.length ? findFibonacciUp(parseFloat(average)) : 'N/A';
// Construct rows for the comment
const header = `| User | Vote |\n|---------------|-----------------|`;
const rows = votesArray.map(({ login, voteField }) =>
`| ${login.padEnd(13)} | ${voteField.padEnd(16)} |`
);
// Combine all parts into the comment body
const resultsSummary = `\n\n**Results Summary:**\n- Average: ${average}\n- Nearest Fibonacci ↗: ${nearestFibonacci}\n- Median: ${median}`;
const commentBody = `***This comment has limited visibility:***\n**Poker Votes:**\n\n${header}\n${rows.join(
'\n'
)}${resultsSummary}`;
// Update or add the comment
if (existingComment) {
if (existingComment.text.trim() === commentBody.trim()) {
workflow.message('No changes detected in votes.'); // When the texts are the same
} else {
existingComment.text = commentBody; // Update the comment if there are differences
existingComment.isUsingMarkdown = true;
existingComment.permittedGroup = ctx.jbTeam;
workflow.message('Voting results have been updated.');
}
} else {
const newComment = issue.addComment(commentBody, ctx.bot); // Add new comment
newComment.isUsingMarkdown = true;
newComment.permittedGroup = ctx.jbTeam;
workflow.message('Poker votes have been posted.');
}
} catch (error) {
console.log('Error occurred:', error.message);
workflow.message('Error processing Poker votes.');
}
},
requirements: {
bot: reqs.user('yt-workflow-bot'),
jbTeam: reqs.group('jetbrains-team'),
PokerData: {
type: entities.Field.stringType,
name: 'Poker data',
},
},
});
// Helper to find the nearest Fibonacci number upwards
function findFibonacciUp(num) {
let a = 0,
b = 1;
while (b < num) {
const temp = b;
b = a + b;
a = temp;
}
return b;
}
// Helper to calculate the median
function calculateMedian(numbers) {
const sorted = [...numbers].sort((a, b) => a - b); // Sort in ascending order
const middle = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 0) {
// Even length: average the two middle numbers
return (sorted[middle - 1] + sorted[middle]) / 2;
} else {
// Odd length: return the middle number
return sorted[middle];
}
}
Workflow 3: Setting Poker Results
The final workflow transparently shares the results in the issue comments. This ensures all team members have visibility into the votes, averages, and final story points.
Code Explanation:
- Summarizes votes in a markdown table.
- Provides additional statistics (average, nearest Fibonacci, etc.).
- Posts a comment or updates an existing one.
const entities = require('@jetbrains/youtrack-scripting-api/entities');
const workflow = require('@jetbrains/youtrack-scripting-api/workflow');
exports.rule = entities.Issue.action({
title: 'Set Story Points from Poker',
command: 'wrs-set-sp',
guard: (ctx) => {
// Ensure 'Poker data' is not empty
return ctx.currentUser.isInGroup('your-group');
},
action: (ctx) => {
const issue = ctx.issue;
try {
// Parse the Poker data
const pokerDataJSON = issue.fields['Poker data'];
if (!pokerDataJSON) {
console.log("Poker data field is empty or missing!");
workflow.message(`Poker data field is empty or missing!`);
return;
}
let pokerData;
try {
pokerData = JSON.parse(pokerDataJSON);
} catch (error) {
console.log("Failed to parse Poker data! Invalid JSON format.");
return;
}
// Extract and process the votes
const votes = pokerData.voteResult
.map(entry => parseFloat(entry.vote)) // Parse numeric votes
.filter(vote => !isNaN(vote)); // Filter valid numbers
if (votes.length === 0) {
console.log("No valid votes found in Poker data");
workflow.message(`No valid votes found in Poker data`);
return;
}
// Calculate the average of the votes
const totalVotes = votes.reduce((sum, vote) => sum + vote, 0);
const average = totalVotes / votes.length;
// Predefined Fibonacci sequence for story points
const fibonacci = [1, 2, 3, 5, 8, 13, 21];
// Find the nearest higher Fibonacci number to the calculated average
const roundedToFibonacci = fibonacci.find(fib => fib >= average) || fibonacci[fibonacci.length - 1];
// Update the Story points field
if (!roundedToFibonacci) {
console.log("Unable to round story points to Fibonacci. Please check the data.");
return;
}
const newStoryPoint = roundedToFibonacci.toString();
issue.fields['Story points'] = newStoryPoint;
workflow.message(`Story points have been set to ${newStoryPoint}.`);
} catch (error) {
// Handle unexpected errors gracefully
console.log("An unexpected error occurred: " + error.message);
}
},
requirements: {
PokerVote: {
type: entities.EnumField.fieldType,
name: "Poker estimation",
multi: false
},
PokerData: {
type: entities.Field.stringType,
name: "Poker data"
},
PokerResult: {
type: entities.Field.integerType,
name: "Story points",
multi: false
}
}
});
Conclusion
By using YouTrack workflows, I successfully added Poker Planning functionality to YouTrack without any third-party tools. Now, teams can vote on estimates, aggregate votes into story points, and transparently view the results, all within YouTrack.
Ready to try this out? Let me know how it works for your team or if you have ideas for further improvement.