From fcbcabb2903e883ada4667e4376ef128a5e3e5e1 Mon Sep 17 00:00:00 2001 From: Daniel Nixon Date: Fri, 6 Sep 2024 15:29:47 +1000 Subject: [PATCH] Format code, parameterise bug issue types --- forecast.ts | 23 ++++++++--------- index.ts | 74 ++++++++++++++++++++++++++++++----------------------- jira.ts | 64 +++++++++++++++++++++++++-------------------- 3 files changed, 89 insertions(+), 72 deletions(-) diff --git a/forecast.ts b/forecast.ts index 817d841..9db4723 100755 --- a/forecast.ts +++ b/forecast.ts @@ -17,7 +17,7 @@ export const calculateTicketTarget = async ( jiraBoardID: string, jiraTicketID: string, tickets: TicketResponse, - userSuppliedTicketTarget: number + userSuppliedTicketTarget: number, ): Promise<{ numberOfTicketsAboveTarget: number; lowTicketTarget: number; @@ -28,7 +28,7 @@ export const calculateTicketTarget = async ( const numberOfTicketsAboveTarget = tickets.issues.indexOf(jiraTicketID); if (numberOfTicketsAboveTarget === -1) { throw new Error( - `Ticket ${jiraTicketID} not found in ticket list for board ${jiraBoardID}` + `Ticket ${jiraTicketID} not found in ticket list for board ${jiraBoardID}`, ); } @@ -47,7 +47,7 @@ export const calculateTicketTarget = async ( // should we treat the two ratios differently, since more bugs tend to be created by // feature tickets and bugs usually don't take as long as features? highTicketTarget: Math.round( - ticketTarget + ticketTarget / bugRatio + ticketTarget / discoveryRatio + ticketTarget + ticketTarget / bugRatio + ticketTarget / discoveryRatio, ), }; }; @@ -67,7 +67,7 @@ export const calculateTicketTarget = async ( export const simulations = async ( resolvedTicketCounts: readonly number[], ticketTarget: number, - numSimulations: number + numSimulations: number, ): Promise => { const results: number[] = Array(numSimulations).fill(0); @@ -82,9 +82,8 @@ export const simulations = async ( let storiesDone = 0; while (storiesDone <= ticketTarget) { const numTimeIntervals = resolvedTicketCounts.length; - storiesDone += resolvedTicketCounts[ - Math.floor(Math.random() * numTimeIntervals) - ]!; + storiesDone += + resolvedTicketCounts[Math.floor(Math.random() * numTimeIntervals)]!; results[i]!++; } } @@ -105,11 +104,11 @@ export const printPredictions = ( simulationResults: readonly number[], numSimulations: number, confidencePercentageThreshold: number, - durationInDays: number + durationInDays: number, ) => { console.log( `Amount of time required to ship ${lowTicketTarget} to ${highTicketTarget} tickets ` + - `(and the number of simulations that arrived at that result):` + `(and the number of simulations that arrived at that result):`, ); const percentages: Record = {}; @@ -160,11 +159,11 @@ export const printPredictions = ( console.log( `${Number(numIntervalsPredicted) * durationInDays} days, ` + `${Math.floor( - cumulativePercentages[numIntervalsPredicted] ?? 0 + cumulativePercentages[numIntervalsPredicted] ?? 0, )}% confidence ` + `(${numSimulationsPredicting[numIntervalsPredicted]} simulation` + // Pluralize - `${numSimulationsPredicting[numIntervalsPredicted] === 1 ? "" : "s"})` + `${numSimulationsPredicting[numIntervalsPredicted] === 1 ? "" : "s"})`, ); } @@ -176,6 +175,6 @@ export const printPredictions = ( }% confident all ` + `${lowTicketTarget} to ${highTicketTarget} tickets will take no more than ${ Number(resultAboveThreshold) * durationInDays - } days to complete.` + } days to complete.`, ); }; diff --git a/index.ts b/index.ts index d3ec702..337c34e 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,7 @@ const numDaysOfHistory = Number.parseInt(process.env.NUM_WEEKS_OF_HISTORY ?? "10") * daysInWeek; const confidencePercentageThreshold = Number.parseInt( - process.env.CONFIDENCE_PERCENTAGE_THRESHOLD ?? "80" + process.env.CONFIDENCE_PERCENTAGE_THRESHOLD ?? "80", ); const numSimulations = Number.parseInt(process.env.NUM_SIMULATIONS ?? "1000"); // length and units are in separate variables @@ -29,7 +29,7 @@ const timeLength = Number.parseInt(process.env.TIME_LENGTH ?? "2"); const timeUnit = process.env.TIME_UNIT ?? "weeks"; const userSuppliedTicketTarget = Number.parseInt( - process.env.TICKET_TARGET ?? "60" + process.env.TICKET_TARGET ?? "60", ); const bugRatioOverride = process.env.BUG_RATIO ? Number.parseInt(process.env.BUG_RATIO) @@ -38,6 +38,18 @@ const discoveryRatioOverride = process.env.DISCOVERY_RATIO ? Number.parseInt(process.env.DISCOVERY_RATIO) : undefined; +// The default Jira bug/fault issue type. Overridable if you use something +// else (or if you use multiple types that should all be considered 'bugs' by the forecast) +const defaultBugIssueTypes = ["Bug"]; +const bugIssueTypes = + process.env.BUG_ISSUE_TYPES === undefined || + process.env.BUG_ISSUE_TYPES === null || + process.env.BUG_ISSUE_TYPES?.trim() === "" + ? defaultBugIssueTypes + : process.env.BUG_ISSUE_TYPES.split(",") + .map((issueType) => issueType.trim()) + .filter((issueType) => issueType !== ""); + // convert provided time interval into days const durationInDays = timeUnit === "days" ? timeLength : timeLength * daysInWeek; @@ -51,7 +63,7 @@ const main = async () => { jiraTicketID === undefined ) { console.error( - `Usage: JIRA_HOST="example.com" JIRA_BOARD_ID=74 JIRA_TICKET_ID=ADE-1234 JIRA_USERNAME=foo JIRA_PASSWORD=bar npm run start` + `Usage: JIRA_HOST="example.com" JIRA_BOARD_ID=74 JIRA_TICKET_ID=ADE-1234 JIRA_USERNAME=foo JIRA_PASSWORD=bar npm run start`, ); return; } @@ -60,7 +72,7 @@ const main = async () => { !(timeUnit === "weeks" || timeUnit === "days" || timeUnit === undefined) ) { console.error( - "Only weeks and days are supported for project interval time units" + "Only weeks and days are supported for project interval time units", ); return; } @@ -74,12 +86,12 @@ const main = async () => { jiraPassword, jiraBoardID, durationInDays, - numDaysOfHistory + numDaysOfHistory, ); // All in progress or to do Jira tickets for the given board (either kanban or scrum). console.log( - `Counting tickets ahead of ${jiraTicketID} in board ${jiraBoardID}...` + `Counting tickets ahead of ${jiraTicketID} in board ${jiraBoardID}...`, ); const tickets = await jira.issuesForBoard(); @@ -96,49 +108,47 @@ const main = async () => { : inferredJiraProjectIDs; const bugRatio = - bugRatioOverride ?? (await jira.fetchBugRatio(jiraProjectIDs)); + bugRatioOverride ?? + (await jira.fetchBugRatio(jiraProjectIDs, bugIssueTypes)); const discoveryRatio = - discoveryRatioOverride ?? (await jira.fetchDiscoveryRatio(jiraProjectIDs)); - const { - numberOfTicketsAboveTarget, - lowTicketTarget, - highTicketTarget, - } = await calculateTicketTarget( - bugRatio, - discoveryRatio, - jiraBoardID, - jiraTicketID, - tickets, - userSuppliedTicketTarget - ); + discoveryRatioOverride ?? + (await jira.fetchDiscoveryRatio(jiraProjectIDs, bugIssueTypes)); + const { numberOfTicketsAboveTarget, lowTicketTarget, highTicketTarget } = + await calculateTicketTarget( + bugRatio, + discoveryRatio, + jiraBoardID, + jiraTicketID, + tickets, + userSuppliedTicketTarget, + ); console.log( - `There are ${tickets.issues.length} tickets in board ${jiraBoardID} that are either in progress or still to do. Of those, ${numberOfTicketsAboveTarget} tickets are ahead of ${jiraTicketID} in priority order.` + `There are ${tickets.issues.length} tickets in board ${jiraBoardID} that are either in progress or still to do. Of those, ${numberOfTicketsAboveTarget} tickets are ahead of ${jiraTicketID} in priority order.`, ); console.log(`Project interval is ${timeLength} ${timeUnit}`); console.log( `The team's past performance will be measured based on tickets in project(s) ${jiraProjectIDs.join( - ", " + ", ", )} that have been resolved in the last ${ numDaysOfHistory / durationInDays - } project intervals (${numDaysOfHistory} days of history will be considered in total).` - ); - const resolvedTicketCounts = await jira.fetchResolvedTicketsPerTimeInterval( - jiraProjectIDs + } project intervals (${numDaysOfHistory} days of history will be considered in total).`, ); + const resolvedTicketCounts = + await jira.fetchResolvedTicketsPerTimeInterval(jiraProjectIDs); await Promise.all( resolvedTicketCounts.map(async (ticketsInTimeInterval, idx) => { console.log( `Resolved ${ticketsInTimeInterval.total} tickets in project interval ${ idx + 1 - }:` + }:`, ); // Print the ticket IDs. This is useful if you're running simulations regularly and saving // the results. console.log(ticketsInTimeInterval.issues.join(", ")); - }) + }), ); if (isFinite(bugRatio)) { @@ -149,7 +159,7 @@ const main = async () => { if (isFinite(discoveryRatio)) { console.log( - `1 new non-bug ticket created for every ${discoveryRatio} tickets resolved.` + `1 new non-bug ticket created for every ${discoveryRatio} tickets resolved.`, ); } else { console.log("No non-bug tickets created."); @@ -157,14 +167,14 @@ const main = async () => { console.log( `If the team continues to create new tickets at this rate, we predict the ${lowTicketTarget} outstanding tickets ` + - `will have grown to ${highTicketTarget} tickets by the time they have all been completed.` + `will have grown to ${highTicketTarget} tickets by the time they have all been completed.`, ); console.log(`Running ${numSimulations} simulations...`); const simulationResults = await simulations( resolvedTicketCounts.map((tickets) => tickets.total), highTicketTarget, - numSimulations + numSimulations, ); printPredictions( @@ -173,7 +183,7 @@ const main = async () => { simulationResults, numSimulations, confidencePercentageThreshold, - durationInDays + durationInDays, ); }; diff --git a/jira.ts b/jira.ts index ad638c3..be9c663 100644 --- a/jira.ts +++ b/jira.ts @@ -45,20 +45,20 @@ const parseJiraResponse = (response: JiraApi.JsonResponse): TicketResponse => { const issuesForScrumBoard = async ( jira: JiraApi, board: JiraBoard, - inProgressOrToDoJql: string + inProgressOrToDoJql: string, ) => { // For scrum boards we have to get all (non-completed) tickets in all active sprints, then all (non-completed) tickets in all future sprints and finally all backlog tickets. const activeSprints = (await jira.getAllSprints( board.id, undefined, undefined, - "active" + "active", )) as SprintResponse; const futureSprints = (await jira.getAllSprints( board.id, undefined, undefined, - "future" + "future", )) as SprintResponse; const allSprintIDs = activeSprints.values @@ -73,22 +73,22 @@ const issuesForScrumBoard = async ( sprintID, undefined, 1000, - inProgressOrToDoJql - ) as Promise - ) + inProgressOrToDoJql, + ) as Promise, + ), ); const currentAndFutureSprintTickets = currentAndFutureSprints.reduce( (previousValue, currentValue) => ({ total: previousValue.total + currentValue.total, issues: previousValue.issues.concat(currentValue.issues), - }) + }), ); // TODO: handle pagination and get all results instead of assuming they will always be less than 1000. const backlogTickets = await jira.getIssuesForBacklog( board.id, undefined, 1000, - inProgressOrToDoJql + inProgressOrToDoJql, ); return { @@ -104,7 +104,7 @@ const issuesForKanbanBoard = async ( board: JiraBoard, inProgressOrToDoJql: string, kanbanInProgressJql: string, - kanbanToDoJql: string + kanbanToDoJql: string, ) => { // For kanban boards we get all in progress tickets and all to do (backlog) tickets. // HACK: The Jira API won't let us use `getIssuesForBacklog` for kanban boards, so we first get in progress tickets, then "to do" tickets, then combine. @@ -115,8 +115,8 @@ const issuesForKanbanBoard = async ( board.id, undefined, 1000, - `(${inProgressOrToDoJql}) and (${kanbanInProgressJql})` - ) + `(${inProgressOrToDoJql}) and (${kanbanInProgressJql})`, + ), ); // TODO: handle pagination and get all results instead of assuming they will always be less than 1000. @@ -125,8 +125,8 @@ const issuesForKanbanBoard = async ( board.id, undefined, 1000, - `(${inProgressOrToDoJql}) and (${kanbanToDoJql})` - ) + `(${inProgressOrToDoJql}) and (${kanbanToDoJql})`, + ), ); return { @@ -151,7 +151,7 @@ export const jiraClient = async ( | string | undefined = `issuetype in standardIssueTypes() and issuetype != Epic and statusCategory in ("To Do", "In Progress")`, kanbanInProgressJql: string | undefined = `statusCategory = "In Progress"`, - kanbanToDoJql: string | undefined = `statusCategory = "To Do"` + kanbanToDoJql: string | undefined = `statusCategory = "To Do"`, ) => { const jira = new JiraApi({ protocol: jiraProtocol, @@ -167,7 +167,7 @@ export const jiraClient = async ( if (board.type !== "kanban" && board.type !== "scrum") { throw new Error( - `Unknown board type [${board.type}] for board [${jiraBoardID}].` + `Unknown board type [${board.type}] for board [${jiraBoardID}].`, ); } @@ -180,7 +180,7 @@ export const jiraClient = async ( */ const issuesForSearchQuery = async ( searchQuery: string, - maxResults: number = 1000 + maxResults: number = 1000, ): Promise => { const issuesResp = await jira.searchJira(searchQuery, { maxResults }); return parseJiraResponse(issuesResp); @@ -195,7 +195,7 @@ export const jiraClient = async ( * @returns An array of number of tickets resolved in each time interval. */ fetchResolvedTicketsPerTimeInterval: async ( - jiraProjectIDs: readonly string[] + jiraProjectIDs: readonly string[], ) => { // We want to know how many tickets were completed during each time interval. If not defined, // our time interval is just any period of two weeks. @@ -206,7 +206,7 @@ export const jiraClient = async ( while (historyStart >= -1 * numDaysOfHistory) { const query = `project in (${jiraProjectIDs.join( - ", " + ", ", )}) AND issuetype in standardIssueTypes() AND issuetype != Epic ` + `AND resolved >= ${historyStart}d AND resolved <= ${historyEnd}d`; @@ -220,22 +220,26 @@ export const jiraClient = async ( }, /** * Gets the bug ratio for "1 bug every X stories" statement. + * @bugIssueTypes The issue types that represents bugs, e.g. Bug, Fault * @returns Number of bugs per stories count. */ - fetchBugRatio: async (jiraProjectIDs: readonly string[]) => { + fetchBugRatio: async ( + jiraProjectIDs: readonly string[], + bugIssueTypes: readonly string[], + ) => { // TODO: this should only count created tickets if they are higher in the backlog than the target ticket or they are already in progress or done. // See https://github.com/agiledigital-labs/probabilistic-forecast/issues/1 const bugsQuery = `project in (${jiraProjectIDs.join( - ", " - )}) AND issuetype = Fault AND created >= -${numDaysOfHistory}d`; + ", ", + )}) AND issuetype IN (${bugIssueTypes.join(",")}) AND created >= -${numDaysOfHistory}d`; const bugCount = (await issuesForSearchQuery(bugsQuery, 0)).total; // Assuming the spreadsheet doesn't count bugs as stories, so exclude bugs in this query. const otherTicketsQuery = `project in (${jiraProjectIDs.join( - ", " + ", ", )}) AND issuetype in standardIssueTypes() ` + - `AND issuetype != Epic AND issuetype != Fault AND created >= -${numDaysOfHistory}d`; + `AND issuetype != Epic AND issuetype NOT IN (${bugIssueTypes.join(",")}) AND created >= -${numDaysOfHistory}d`; const otherTicketCount = ( await issuesForSearchQuery(otherTicketsQuery, 0) ).total; @@ -244,23 +248,27 @@ export const jiraClient = async ( }, /** * Gets the new story ratio for "1 new story [created] every X stories [resolved]" statement. + * @bugIssueTypes The issue types that represents bugs, e.g. Bug, Fault * @returns Number of new stories created per resolved stories count. */ - fetchDiscoveryRatio: async (jiraProjectIDs: readonly string[]) => { + fetchDiscoveryRatio: async ( + jiraProjectIDs: readonly string[], + bugIssueTypes: readonly string[], + ) => { // TODO: this should only count created tickets if they are higher in the backlog than the target ticket or they are already in progress or done. // See https://github.com/agiledigital-labs/probabilistic-forecast/issues/1 const nonBugTicketsCreatedQuery = `project in (${jiraProjectIDs.join( - ", " + ", ", )}) AND issuetype in standardIssueTypes() ` + - `AND issuetype != Epic AND issuetype != Fault AND created >= -${numDaysOfHistory}d`; + `AND issuetype != Epic AND issuetype NOT IN (${bugIssueTypes.join(",")}) AND created >= -${numDaysOfHistory}d`; const nonBugTicketsCreatedCount = ( await issuesForSearchQuery(nonBugTicketsCreatedQuery, 0) ).total; const ticketsResolvedQuery = `project in (${jiraProjectIDs.join( - ", " + ", ", )}) AND issuetype in standardIssueTypes() ` + `AND issuetype != Epic AND resolved >= -${numDaysOfHistory}d`; const ticketsResolvedCount = ( @@ -282,7 +290,7 @@ export const jiraClient = async ( board, inProgressOrToDoJql, kanbanInProgressJql, - kanbanToDoJql + kanbanToDoJql, ); }, };