Skip to content

Commit

Permalink
Merge branch 'develop' of https://github.com/shridhar-tl/jira-assistant
Browse files Browse the repository at this point in the history
… into package
  • Loading branch information
shridhar-tl committed Dec 28, 2024
2 parents 136a5a6 + 47c58b6 commit 5e97915
Show file tree
Hide file tree
Showing 6 changed files with 141 additions and 63 deletions.
4 changes: 2 additions & 2 deletions src/jcloud-events.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { asApp, route } from '@forge/api'

export async function onSprintStarted(event, context) {
export async function onSprintStarted(event) {
if (event.eventType !== "avi:jira-software:started:sprint") {
console.error("Invalid event type received", event.eventType);
}
Expand All @@ -20,7 +20,7 @@ export async function onSprintStarted(event, context) {
console.error("onSprintStarted: Story points field not found");
}

const response = await asApp().requestJira(route`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=1000&fields=key,${storyPointFieldId}`, {
const response = await asApp().requestJira(route`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=1000&fields=key,${storyPointFieldId}&jql=${encodeURIComponent('issuetype not in subTaskIssueTypes()')}`, {
headers: {
'Accept': 'application/json'
}
Expand Down
98 changes: 65 additions & 33 deletions src/services/jira-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ApiUrls } from '../constants/api-urls';
import * as moment from 'moment';
import { mergeUrl, prepareUrlWithQueryString, viewIssueUrl, waitFor } from '../common/utils';
import FeedbackPromise from 'src/common/FeedbackPromise';
import { isPluginBuild } from 'src/constants/build-info';

export default class JiraService {
static dependencies = ["AjaxService", "CacheService", "MessageService", "SessionService"];
Expand Down Expand Up @@ -437,49 +438,20 @@ export default class JiraService {
}

getSprintIssues(sprintIds, options) {
const { worklogStartDate, worklogEndDate, includeRemoved, ...opts } = options || {};
const { worklogStartDate, worklogEndDate, includeRemoved, boardId, ...opts } = options || {};

const worker = async (sprintId) => {
const { issues } = await this.$ajax.get(ApiUrls.getSprintIssues.format(sprintId), opts);

try { // if includeRemoved is true, then fetch removed issues based on custom properties added in sprint and add them to the list
const removedIssues = includeRemoved ? await this.getSprintProperty(sprintId, 'jaSprintStartInfo') : null;
if (removedIssues) {
const removedIssueKeys = Object.keys(removedIssues);
let jql = `key in (${removedIssueKeys.join(',')})`;

if (opts?.jql) {
jql = `(${opts.jql}) AND (${jql})`;
}

const closedTickets = await this.searchTickets(jql, options?.fields, 0, { ignoreErrors: true });
if (closedTickets?.length) {
const issuesMap = issues.reduce((obj, issue) => {
obj[issue.key] = issue;
return obj;
}, {});

closedTickets.forEach(t => {
let existingIssue = issuesMap[t.key];
if (!existingIssue) {
issues.push(t);
existingIssue = t;
}
if (removedIssues[t.key] && "sp" in removedIssues[t.key]) {
existingIssue.initialStoryPoints = parseFloat(removedIssues[t.key].sp) || 0;
}
});
}
}
} catch (e) {
console.error("Error trying to retrieve removed issues for sprint", sprintId, e);
if (includeRemoved) {
await this.addRemovedIssuesToList(boardId, sprintId, issues, options?.fields, opts.jql);
}

if (options?.fields?.indexOf("worklog") > -1) {
await this.fillMissingWorklogs(issues, worklogStartDate, worklogEndDate);
}

return issues.sortBy(t => t.key);
return issues.sortBy(t => parseInt(t.id));
};


Expand All @@ -501,6 +473,66 @@ export default class JiraService {
}
}

// Based on internal API, try to find the list of removed issues part of sprint
async getRemovedIssuesWithStoryPointForSprint(boardId, sprintId) {
const details = await this.getRapidSprintDetails(boardId, sprintId);
const addedLater = details?.contents?.issueKeysAddedDuringSprint || {};

const getIssueObject = (issues, issueMap) => issues?.reduce((map, t) => {
if (addedLater[t.key]) { return map; }

const sp = t?.estimateStatistic?.statFieldValue?.value || 0;
map[t.key] = { sp };
return map;
}, issueMap || {}) ?? {};

let result = getIssueObject(details?.contents?.completedIssues);
result = getIssueObject(details?.contents?.issuesNotCompletedInCurrentSprint, result);
result = getIssueObject(details?.contents?.issuesCompletedInAnotherSprint, result);
result = getIssueObject(details?.contents?.puntedIssues, result);

return result;
}

async addRemovedIssuesToList(boardId, sprintId, issues, fieldsList, optsJql) {
try { // if includeRemoved is true, then fetch removed issues based on custom properties added in sprint and add them to the list
const issuesMapAtTheBeginningOfSprint = isPluginBuild ?
await this.getSprintProperty(sprintId, 'jaSprintStartInfo') // If currently running as plugin, then issues list is stored in property
: await this.getRemovedIssuesWithStoryPointForSprint(boardId, sprintId); // For extension/web users, get the details by calling internal reports api

if (issuesMapAtTheBeginningOfSprint) {
const allIssueKeys = Object.keys(issuesMapAtTheBeginningOfSprint);

if (!allIssueKeys.length) { return; }

const removedIssueKeys = allIssueKeys.filter(key => !issues.some(t => t.key === key)); // Remove all the issues which is already part of list

if (removedIssueKeys.length) {
let jql = `key in (${removedIssueKeys.join(',')})`;

if (optsJql) {
jql = `(${optsJql}) AND (${jql})`;
}

const closedTickets = await this.searchTickets(jql, fieldsList, 0, { ignoreErrors: true });
closedTickets.forEach(t => t.removedFromSprint = true); // Set the removed from sprint flag to true
if (closedTickets?.length) {
issues.push(...closedTickets);
}
}

issues.forEach(t => {
const issueValue = issuesMapAtTheBeginningOfSprint[t.key];
if (issueValue && "sp" in issueValue) {
t.initialStoryPoints = parseFloat(issueValue.sp) || 0;
}
});
}
} catch (e) {
console.error("Error trying to retrieve removed issues for sprint", sprintId, e);
}
}

getOpenTickets(refresh) {
if (!refresh) {
const value = this.$jaCache.session.get("myOpenTickets");
Expand Down
72 changes: 60 additions & 12 deletions src/services/sprint-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default class SprintService {
computeAverageSprintVelocity = (boardId, noOfSprintsForVelocity = 6, storyPointFieldName,
sprintFieldId, noOfSprintsToPull = noOfSprintsForVelocity * 2, workingDays) => new FeedbackPromise(async (resolve, _, progress) => {
const allClosedSprintLists = await this.$jira.getRapidSprintList([boardId], { state: 'closed' });
progress({ completed: 5 });
progress({ completed: 2 });
const closedSprintLists = allClosedSprintLists.slice(0, noOfSprintsToPull).sortBy(({ completeDate }) => completeDate.getTime());
const availableSprintCount = closedSprintLists.length;

Expand All @@ -25,24 +25,17 @@ export default class SprintService {
const sprintWiseIssues = await this.$jira.getSprintIssues(sprintIds, {
jql: 'issuetype not in subTaskIssueTypes()',
fields: ['created', 'resolutiondate', storyPointFieldName],
includeRemoved: true
includeRemoved: true, boardId
}).progress(done => progress({ completed: done / 2 }));

const issueLogs = await getIssueLogsForSprints(closedSprintLists, this.$jira, sprintWiseIssues, progress, sprintFieldId, storyPointFieldName);

for (let index = 0; index < closedSprintLists.length; index++) {
const sprint = closedSprintLists[index];
const startDate = moment(sprint.startDate);
const completeDate = moment(sprint.completeDate);
sprint.issues = sprintWiseIssues[sprint.id];

let issueLogs = await this.$jira.getBulkIssueChangelogs(sprint.issues.map(({ key }) => key),
['status', sprintFieldId, storyPointFieldName]);
progress({ completed: 50 + ((index + 1) * 50 / closedSprintLists.length) });

if (!issueLogs) {
sprint.logUnavailable = true;
issueLogs = {};
}

sprint.committedStoryPoints = 0;
sprint.completedStoryPoints = 0;
sprint.sayDoRatio = 0;
Expand Down Expand Up @@ -125,7 +118,7 @@ export default class SprintService {
}
}

if (!issue.addedToSprint) {
if (!issue.addedToSprint && issue.initialStoryPoints) {
sprint.committedStoryPoints += issue.initialStoryPoints;
}

Expand Down Expand Up @@ -185,4 +178,59 @@ function getFirstModifiedLog(logs, fieldId, fromString, toString) {
return item;
}
}
}

async function getIssueLogsForSprints(closedSprintLists, $jira, sprintWiseIssues, updateProgress, sprintFieldId, storyPointFieldName) {
let issueLogs = {};

for (let index = 0; index < closedSprintLists.length; index++) {
const sprint = closedSprintLists[index];
const firstTimeIssuesToPull = sprintWiseIssues[sprint.id].filter(issue => !issueLogs[issue.key]);
const issueLogsToPull = firstTimeIssuesToPull.map(({ key }) => key);
const sprintIssueLogs = await $jira.getBulkIssueChangelogs(issueLogsToPull, ['status', sprintFieldId, storyPointFieldName]);

// Always add 50%, as 50% is already completed as part of pulling ticket details for sprint. Remaining 50% is for pulling change logs
updateProgress({ completed: 50 + ((index + 1) * 50 / closedSprintLists.length) });

if (sprintIssueLogs) {
issueLogs = { ...issueLogs, ...sprintIssueLogs };
addRemovedIssuesToMissingSprints(sprintWiseIssues, sprintIssueLogs, sprint.id, firstTimeIssuesToPull, sprintFieldId);
} else {
sprint.logUnavailable = true;
}
}

return issueLogs;
}

// If an issue is removed from sprint in between, then Jira does not provide any option to pull those issues.
// So looking at change logs for each issue in sprints, this function will add stories to individual sprints
// This is a workaround primarily for extensions and web versions.
function addRemovedIssuesToMissingSprints(sprintWiseIssues, sprintIssueLogs, currentSprintId, firstTimeIssuesToPull, sprintFieldId) {
firstTimeIssuesToPull.forEach(issue => {
const key = issue.key;
const logsForCurrentTicket = sprintIssueLogs[key]?.filter(l => l.fieldId === sprintFieldId);

// Take the list of sprints current issue is part of
const sprintIdsFromCurrentTicket = logsForCurrentTicket?.flatMap(l => [...(l.from?.split(',') || []), ...(l.to?.split(',') || [])]).filter(sid => !!sid).distinct();

if (!sprintIdsFromCurrentTicket?.length) { return; }

// Based on list of sprints, add that ticket to all those sprints if that ticket is not already available
sprintIdsFromCurrentTicket.forEach((sprintId) => {
if (parseInt(sprintId) === currentSprintId) { return; } // In current sprint already ticket would exist. Hence no need of checking

const sprintIssues = sprintWiseIssues[sprintId];

// Its not necessary that sprint for all the sprint id is available in this list
if (!sprintIssues?.length) { return; }

if (!sprintIssues.some(t => t.key === key)) {
sprintIssues.push({ // Clone the issue as it would be mutated later
...issue,
fields: { ...(issue.fields || {}) }
});
}
});
});
}
2 changes: 1 addition & 1 deletion src/views/reports/say-do-ratio/SayDoRatioReport.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
td.log-indi-cntr {
td.sprint-info-cell {
cursor: pointer;

.fa-info-circle {
Expand Down
19 changes: 13 additions & 6 deletions src/views/reports/say-do-ratio/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ function SayDoRatioReport() {
const loadReportData = React.useCallback(async () => {
try {
setProgress(0);
setSprint(null);
setReportData([]);
setLoader(true);
const reportData = await getSprintWiseSayDoRatio($this.current.settings).progress(({ completed, data }) => {
setProgress(completed);
if (data) {
setReportData(data);
setReportData(data);
}
});
setReportData(reportData);
Expand Down Expand Up @@ -77,15 +79,16 @@ function SayDoRatioReport() {
<td className="text-center">{b.velocity || '-'} {!!b.velocity && <span>({parseFloat(b.velocityGrowth?.toFixed(2) || 0)}%)</span>}</td>
<td className={getLogClass(b.sayDoRatio)}>
{formatValue(b.sayDoRatio)}
{b.sayDoRatio && <Indicator value={b.sayDoRatio} maxHours={100} />}
{!!b.sayDoRatio && <Indicator value={b.sayDoRatio} maxHours={100} />}
</td>
<td className="text-center">{b.averageCycleTime ? `${b.averageCycleTime} days` : '-'}</td>
{b.sprintList.map(s => (s?.sayDoRatio ? (<td className={getLogClass(s.sayDoRatio, s === selectedSprint)} onClick={() => setSprint(s)} key={s.id}>
{b.sprintList.map(s => (s ? (<td className={`sprint-info-cell ${getLogClass(s.sayDoRatio, s === selectedSprint)}`} onClick={() => setSprint(s)} key={s.id}>
<span className="fas fa-info-circle float-end" />
{s.sayDoRatio}%
{!!s.sayDoRatio && <span>{s.sayDoRatio}%</span>}
{!s.sayDoRatio && <span>-</span>}
{!b.logUnavailable && s.logUnavailable && <span className="fas fa-exclamation-triangle msg-warning" title={changeLogErrorMessage} />}
<Indicator value={parseInt(s.sayDoRatio)} maxHours={100} />
</td>) : <td className="text-center">-</td>))}
{!!s.sayDoRatio && <Indicator value={parseInt(s.sayDoRatio)} maxHours={100} />}
</td>) : <td className="text-center" key={s.id}>-</td>))}
</tr>}
</TBody>
{!reportData?.length && <NoDataRow span={7}>No data available.</NoDataRow>}
Expand Down Expand Up @@ -142,6 +145,10 @@ function formatValue(value, suffix = "%", defaultValue = '') {
}

function getLogClass(value, isSelected) {
if (!value) {
return isSelected ? 'text-center selected' : 'text-center';
}

let className = 'log-good';

if (value >= 85) {
Expand Down
9 changes: 0 additions & 9 deletions src/views/reports/say-do-ratio/settings/index.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React from 'react';
import { isPluginBuild } from 'src/constants/build-info';
import SideBar from '../../pivot-report/editor/SideBar';
import RapidViewList from '../../../../components/RapidViewList';
import { Button, Checkbox, TextBox } from 'src/controls';
Expand Down Expand Up @@ -60,14 +59,6 @@ function ReportSettings({ settings: actualSettings, show, onHide, onDone }) {
Select value for "Story Points field" under General settings -&gt; "Default Values" tab.
Report cannot be generated without having "Story Points field" configured.
</div>}
{!isPluginBuild && <div className="p-3">
<Checkbox checked={true} disabled label="Do not show issues removed from sprint as committed" />
<div className="help-text d-block mt-1">
If an issue is removed from sprint before closing it, then it would not be considered as committed which impacts Sa-Do-Ratio.
<br />
Note: As of now Jira doesn't support pulling issues which are removed from sprint.
</div>
</div>}
<div className="p-3">
<Checkbox checked={settings.includeNonWorkingDays} field="includeNonWorkingDays" onChange={setBoolean}
label="Include non working days in cycle time calculation" />
Expand Down

0 comments on commit 5e97915

Please sign in to comment.