private void AllocateTimeToWorkItems(TimeEntry timeEntry) { var referencedStoryIds = MavenlinkHelper.GetJiraStoryIds(timeEntry.NotesOverride); if (referencedStoryIds.Count == 0) { return; } decimal timePerStory = (referencedStoryIds.Count == 0) ? (decimal)timeEntry.DurationMinutesOverride : (decimal)((decimal)timeEntry.DurationMinutesOverride / referencedStoryIds.Count); var workItemsInLocalCache = Database.WorkItems .Where(x => referencedStoryIds.Contains(x.StoryNumber)) .OrderBy(x => x.StoryNumber) .ToList(); foreach (var workItem in workItemsInLocalCache) { timeEntry.WorkItems.Add( new TimeEntryWorkItemAllocation { TimeEntry = timeEntry, WorkItem = workItem, DurationMinutes = timePerStory } ); } }
public async Task <List <TimeEntryValidationResult> > Validate(Release release, List <TimeEntry> timeEntries) { var jiraStoriesInRelease = (await JiraApi.GetStoriesInReleaseAsync(release.ReleaseNumber)); var results = new List <TimeEntryValidationResult>(); foreach (var timeEntry in timeEntries) { var errors = new List <string>(); var warnings = new List <string>(); var isPlanned = (MavenlinkHelper.GetBillingClassification(timeEntry.TaskTitleOverride) == BillingClassificationEnum.Planned); var referencedStoryIds = MavenlinkHelper.GetJiraStoryIds(timeEntry.NotesOverride); if (referencedStoryIds.Count <= 1 && timeEntry.DurationMinutesOverride < 15) { warnings.Add($"Minimum billing increment is 15 minutes"); } else if (referencedStoryIds.Count > 1) { var timePerStory = (timeEntry.DurationMinutesOverride / referencedStoryIds.Count); if (timePerStory < 15m) { warnings.Add($"Each Jira story would receive {timePerStory} minutes of elapsed time, less than the 15-min minimum billing increment."); } } foreach (var jiraStoryId in referencedStoryIds) { var jiraStory = jiraStoriesInRelease .Where(x => x.Id == jiraStoryId) .FirstOrDefault(); // we're loading all of the stories in the release in a single load for performance, but if time is // billed to a story NOT in the release we still need to load it as a 1-off. if (jiraStory == null) { try { jiraStory = await JiraApi.GetStoryAsync(jiraStoryId); } catch (NotFoundException) { errors.Add($"Jira ID '{jiraStoryId}' was not found in Jira"); continue; } } // Billing time to a story not associated with the release is a warning; the time will be counted towards // the "undelivered" category and will not affect the metrics if (!jiraStory.FixVersions.Contains(release.ReleaseNumber)) { warnings.Add($"Jira {jiraStory.IssueType} '{jiraStoryId}' is not tagged to release {release.ReleaseNumber} and has status '{jiraStory.Status}'. Time will be counted towards 'undelivered'."); } var workItemType = JiraHelper.GetWorkItemType(jiraStory); var isEpic = (workItemType == WorkItemTypeEnum.Epic); var isDeclined = jiraStory.Status.EqualsIgnoringCase("Declined"); var isContingency = (workItemType == WorkItemTypeEnum.Contingency); var isFeatureRequest = (workItemType == WorkItemTypeEnum.FeatureRequest); if (isFeatureRequest) { warnings.Add($"'{jiraStoryId}' is a Feature Request; Feature Requests should not be billed as development, they should either be billed to a specific analysis ticket or to Unplanned Analysis."); } if (isDeclined) { warnings.Add($"Jira {jiraStory.IssueType} '{jiraStoryId}' is marked as 'DECLINED'. Time will be tracked towards 'undelivered'."); } if (isEpic && isPlanned) { warnings.Add($"'{jiraStoryId}' is an Epic; epics should generally have Overhead or Unplanned time instead of Planned."); } if (isContingency) { errors.Add($"'{jiraStoryId}' is a Contingency; Contingency cases should not have time billed to them. The time (and contingency points) should be moved to an actual feature."); } } if (errors.Any() || warnings.Any()) { results.Add( new TimeEntryValidationResult(timeEntry.Id, errors, warnings) ); } } return(results); }