Skip to content

Commit

Permalink
Merge branch 'develop' into package
Browse files Browse the repository at this point in the history
  • Loading branch information
shridhar-tl committed Dec 23, 2024
2 parents d148bc3 + ad9ccc6 commit 205afce
Show file tree
Hide file tree
Showing 16 changed files with 480 additions and 59 deletions.
7 changes: 7 additions & 0 deletions manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,15 @@ permissions:
- read:jql:jira
- read:jira-work
- write:jira-work
- read:board-scope:jira-software
- read:project:jira
- read:issue-meta:jira
- read:avatar:jira
- read:issue.changelog:jira
app:
id: ari:cloud:ecosystem::app/3864d3bc-aad3-4650-ac35-e15af61fd92d
features:
autoUserConsent: true
runtime:
name: nodejs20.x
remotes:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.3.1",
"react-router-dom": "7.0.2",
"react-router-dom": "7.1.0",
"react-scripts": "github:shridhar-tl/react-scripts",
"static-eval": "2.1.1",
"zustand": "5.0.2"
Expand Down
1 change: 1 addition & 0 deletions src/constants/api-urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ApiUrls = {
rapidSprintDetails: "~/rest/greenhopper/1.0/rapid/charts/sprintreport?rapidViewId={0}&sprintId={1}",
sprintListAll: "~/rest/greenhopper/1.0/integration/teamcalendars/sprint/list?jql=project+in+({0})",
getSprintIssues: "~/rest/agile/1.0/sprint/{0}/issue",
bulkIssueChangelogs: '~/rest/api/3/changelog/bulkfetch',
rapidViews: "~/rest/greenhopper/1.0/rapidview",
scrumBoards: "~/rest/agile/1.0/board?maxResults=100&orderBy=name&type=scrum,simple",
scrumBoardConfig: "~/rest/agile/1.0/board/{0}/configuration",
Expand Down
2 changes: 1 addition & 1 deletion src/constants/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { JAApiBasePath, JAWebRootUrl } from "./urls";

// #region Jira Cloud OAuth2
export const jiraCloudAuthorizeUrl = 'https://auth.atlassian.com/authorize';
export const jiraCloudScopes = 'offline_access read:jira-user read:jira-work write:jira-work read:sprint:jira-software read:issue-details:jira read:jql:jira';
export const jiraCloudScopes = 'offline_access read:jira-user read:jira-work write:jira-work read:sprint:jira-software read:issue-details:jira read:jql:jira read:board-scope:jira-software read:project:jira read:issue-meta:jira read:avatar:jira read:issue.changelog:jira';
export const jiraCloudClientId = 'WcuXzz2GICjwK6ZUMSlJwcDbTaIC31B6';
export const jiraCloudRedirectUrl = `${JAWebRootUrl}?oauth=jc`;
export const jaJiraTokenExchangeUrl = `${JAApiBasePath}/oauth/jira/token`;
Expand Down
8 changes: 7 additions & 1 deletion src/display-controls/TimeSpentDisplay.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ class TimeSpentDisplay extends BaseControl {

if (!timespent) { return badge; }

if (inputType === "ticks" && timespent > 500) {
if (inputType === "days") {
const mins = timespent * 24 * 60;
timespent = parseInt(mins) * 60; // Ignore the seconds part
if (!timespent && mins) {
timespent = { text: '< 1m' };
}
} else if (inputType === "ticks" && timespent > 500) {
timespent = parseInt(timespent / 1000);
}

Expand Down
2 changes: 1 addition & 1 deletion src/gadgets/Calendar/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class CalendarSettings extends BaseDialog {
<label className="col-md-3 col-form-label">Worklog entry color</label>
<div className="col-md-9 col-form-label">
<ColorPicker value={settings.worklogColor} fieldName="worklogColor" onChange={this.setValue} />
<label className="form-check-label block">
<label className="form-check-label d-block">
Specify the color of the worklog entry
</label>
</div>
Expand Down
20 changes: 19 additions & 1 deletion src/services/jira-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ export default class JiraService {
await this.fillMissingWorklogs(issues, worklogStartDate, worklogEndDate);
}

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

if (!Array.isArray(sprintIds)) {
Expand Down Expand Up @@ -583,6 +583,24 @@ export default class JiraService {
});
}

async getBulkIssueChangelogs(issueIdsOrKeys, fieldIds) {
try {
const { issueChangeLogs } = await this.$ajax.post(ApiUrls.bulkIssueChangelogs, {
maxResults: 10000,
issueIdsOrKeys,
fieldIds
});

return issueChangeLogs.reduce((obj, item) => {
obj[item.issueId] = item.changeHistories.sortBy(ch => ch.created).flatMap(({ items, ...ch }) => items.map(i => ({ ...ch, ...i })));
return obj;
}, {});
} catch (err) {
console.error("Unable to fetch changelogs for tickets: ", err);
return {};
}
}

fillWL(issue, startDate, endDate) {
console.log(`Started fetching worklog for ${issue.key} between ${startDate} & ${endDate}`);
return this.getWorklogs(issue.key, startDate, endDate).then((res) => {
Expand Down
156 changes: 132 additions & 24 deletions src/services/sprint-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default class SprintService {
this.$jira = $jira;
}

computeAverageSprintVelocity = async (boardId, noOfSprintsForVelocity = 6, storyPointFieldName, noOfSprintsToPull = noOfSprintsForVelocity * 2) => {
computeAverageSprintVelocity = async (boardId, noOfSprintsForVelocity = 6, storyPointFieldName, sprintFieldId, noOfSprintsToPull = noOfSprintsForVelocity * 2) => {
const allClosedSprintLists = await this.$jira.getRapidSprintList([boardId], { state: 'closed' });
const closedSprintLists = allClosedSprintLists.slice(0, noOfSprintsToPull).sortBy(({ completeDate }) => completeDate.getTime());
const availableSprintCount = closedSprintLists.length;
Expand All @@ -17,50 +17,158 @@ export default class SprintService {
}

const sprintIds = closedSprintLists.map(({ id }) => id);
const storyPointFieldForQuery = storyPointFieldName.startsWith('customfield_')
? `cf[${storyPointFieldName.split('_')[1]}]`
: storyPointFieldName;

const sprintWiseIssues = await this.$jira.getSprintIssues(sprintIds, {
jql: `${storyPointFieldForQuery} > 0`,
fields: ['resolutiondate', storyPointFieldName]
jql: 'issuetype not in subTaskIssueTypes()',
fields: ['created', 'resolutiondate', storyPointFieldName]
});

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

const issueLogs = await this.$jira.getBulkIssueChangelogs(sprint.issues.map(({ key }) => key),
['status', sprintFieldId, storyPointFieldName]);

sprint.committedStoryPoints = 0;
sprint.completedStoryPoints = 0;
sprint.sayDoRatio = 0;
const cycleTimes = [];

sprint.issues.forEach(issue => { // eslint-disable-line complexity
const { resolutiondate, [storyPointFieldName]: storyPoint, created: issueCreated } = issue.fields;
issue.fields.storyPoints = storyPoint;
let $resolutiondate = resolutiondate && moment(resolutiondate);

if ($resolutiondate && $resolutiondate.isBefore(startDate)) {
issue.completedOutside = true;
return;
}

issues.forEach(issue => {
const { resolutiondate, [storyPointFieldName]: storyPoint } = issue.fields;
const allLogs = issueLogs[issue.id];
const modifiedWithinSprint = allLogs?.filter(log => moment(log.created).isBetween(startDate, completeDate));

const sprintFields = modifiedWithinSprint?.filter(log => log.fieldId === sprintFieldId);
const firstSprintLog = sprintFields?.[0];
//const lastSprintLog = sprintFields?.[0];

//issue.removedFromSprint = lastSprintLog && !lastSprintLog.to.split(',').some(sid => parseInt(sid) === sprint.id);
issue.addedToSprint = startDate.isBefore(issueCreated)
|| (firstSprintLog && !firstSprintLog.from.split(',').some(sid => parseInt(sid) === sprint.id));

issue.initialStoryPoints = parseInt(storyPoint) || 0;

if (allLogs?.length) {
const spLog = getFirstModifiedLog(modifiedWithinSprint, storyPointFieldName);
if (spLog) {
issue.initialStoryPoints = parseInt(spLog.fromString) || 0;
} else {
const spModifiedAfterSprint = allLogs.filter(log => log.fieldId === storyPointFieldName && moment(log.created).isAfter(completeDate))[0];
if (spModifiedAfterSprint) {
issue.initialStoryPoints = parseInt(spModifiedAfterSprint.fromString) || 0;
}
}
}

if (resolutiondate && moment(resolutiondate).isBetween(startDate, completeDate)) {
sprint.completedStoryPoints += storyPoint;
if (modifiedWithinSprint?.length) {
if (!$resolutiondate || $resolutiondate.isAfter(completeDate)) { // If the issue is reopened after Done, then there would be no resolution date
const listOfStatusChanges = modifiedWithinSprint.filter(log => log.fieldId === 'status');
const lastStatus = listOfStatusChanges.length > 0 && listOfStatusChanges[listOfStatusChanges.length - 1];
const lastStatusText = (lastStatus && lastStatus.toString?.toLowerCase()) || '';

if (lastStatusText === 'done' || lastStatusText.includes('closed')) {
$resolutiondate = moment(lastStatus.created); // use the date completed within sprint as resolution date
console.log('Discrepancy found. Issue status toggled within sprint:', sprint.name, 'issue:', issue.key);
} else if ($resolutiondate) {
$resolutiondate = completeDate; // use the sprint completed date as resolution date if the issue is reopened within sprint
console.log('Discrepancy found. Issue reopened after Done:', sprint.name, 'issue:', issue.key);
}
}
}

sprint.committedStoryPoints += storyPoint;
if ($resolutiondate && !issue.removedFromSprint) {
const hasResolvedWithinSprint = $resolutiondate.isBetween(startDate, completeDate);

if (hasResolvedWithinSprint && allLogs?.length) {
const statusLog = getFirstModifiedLog(allLogs, 'status', 'To Do');
const firstClosed = getFirstModifiedLog(allLogs, 'status', undefined, ['done', 'closed']);
if (statusLog && (!firstClosed || startDate.isBefore(firstClosed.created))) { // If ticket is once closed before sprint start, then is should not be considered for cycle time
const dateToUse = completeDate.isBefore($resolutiondate) ? completeDate : $resolutiondate;
const ct = dateToUse.diff(statusLog.created, 'days', true) || 0;
if (ct > 0) {
issue.cycleTime = ct;
cycleTimes.push(issue.cycleTime);
}
}
}

if (hasResolvedWithinSprint) {
sprint.completedStoryPoints += (storyPoint || 0);
issue.completed = true;
} else if (storyPoint) {
console.log('Discrepancy found. Issue resolution date is in appropriate:', sprint.name, 'issue:', issue.key);
}
}

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

if (issue.initialStoryPoints === storyPoint) {
delete issue.initialStoryPoints;
}
});

sprint.sayDoRatio = parseFloat((sprint.completedStoryPoints * 100 / sprint.committedStoryPoints).toFixed(2));
sprint.averageCycleTime = cycleTimes.avg();
sprint.cycleTimesIssuesCount = cycleTimes.length;
if (sprint.completedStoryPoints) {
if (sprint.committedStoryPoints > sprint.completedStoryPoints) {
sprint.sayDoRatio = parseFloat((sprint.completedStoryPoints * 100 / sprint.committedStoryPoints).toFixed(2));
} else {
sprint.sayDoRatio = 100;
}
}

if (index) {
const sprints = closedSprintLists.slice(index <= noOfSprintsForVelocity ? 0 : index - noOfSprintsForVelocity, index);
sprint.averageCommitted = Math.round(sprints.sum(s => s.committedStoryPoints) / sprints.length);
sprint.velocity = Math.round(sprints.sum(s => s.completedStoryPoints) / sprints.length);
const sprintToCalculate = closedSprintLists.slice(index <= noOfSprintsForVelocity ? 0 : index - noOfSprintsForVelocity);
sprint.velocity = Math.round(sprintToCalculate.avg(s => s.completedStoryPoints));
const prevSprint = closedSprintLists[index - 1];
const prevVelocity = index === 1 ? prevSprint?.completedStoryPoints : prevSprint?.velocity;
if (prevVelocity) {
sprint.velocityGrowth = ((sprint.velocity - prevVelocity) * 100) / prevVelocity;
}
}
});
}

const sprintsToConsider = closedSprintLists.slice(-noOfSprintsForVelocity);

const averageCommitted = Math.round(sprintsToConsider.sum(s => s.committedStoryPoints) / sprintsToConsider.length);
const averageCompleted = Math.round(sprintsToConsider.sum(s => s.completedStoryPoints) / sprintsToConsider.length);
const sayDoRatio = parseFloat((averageCompleted * 100 / averageCommitted).toFixed(2));
const diff = Math.abs(averageCommitted - averageCompleted);
const median = Math.round(averageCompleted + (diff / 2));
const averageCycleTime = parseFloat((sprintsToConsider.avg(s => s.averageCycleTime)).toFixed(2));
const averageCommitted = Math.round(sprintsToConsider.avg(s => s.committedStoryPoints));
const averageCompleted = Math.round(sprintsToConsider.avg(s => s.completedStoryPoints));
const sayDoRatio = Math.round(sprintsToConsider.avg(s => s.sayDoRatio));

const previousSprint = closedSprintLists[closedSprintLists.length - 1];
const boardVelocity = previousSprint?.velocity;
const velocityGrowth = previousSprint?.velocityGrowth;
//const diff = Math.abs(averageCommitted - averageCompleted);
//const median = Math.round(averageCompleted + (diff / 2));

return { closedSprintLists, averageCommitted, median, averageCompleted, sayDoRatio };
return { closedSprintLists, averageCommitted, averageCompleted, velocity: boardVelocity, velocityGrowth, sayDoRatio, averageCycleTime };
};
}

function getFirstModifiedLog(logs, fieldId, fromString, toString) {
if (!logs?.length) {
return;
}

for (const item of logs) {
if (item.fieldId === fieldId
&& (!fromString || item.fromString === fromString)
&& (!toString || item.toString === toString || (Array.isArray(toString) && toString.some(v => item.toString?.toLowerCase().includes(v.toLowerCase()))))) {
return item;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function PivotConfig() {
export default PivotConfig;

const accept = ['jira-field'];
const fieldPlaceholder = (<span className="p-3 block">Drag and drop required fields from list to be shown in report</span>);
const fieldPlaceholder = (<span className="p-3 d-block">Drag and drop required fields from list to be shown in report</span>);
function FieldsConfig() {
const fields = usePivotConfig(({ fields }) => fields);

Expand Down
Loading

0 comments on commit 205afce

Please sign in to comment.