You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
636 lines
15 KiB
636 lines
15 KiB
package sprints
|
|
|
|
import (
|
|
"context"
|
|
"git.aiterp.net/stufflog3/stufflog3/entities"
|
|
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
|
|
"git.aiterp.net/stufflog3/stufflog3/models"
|
|
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
|
|
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
|
|
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
|
|
"golang.org/x/sync/errgroup"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Service struct {
|
|
Scopes *scopes.Service
|
|
Items *items.Service
|
|
Projects *projects.Service
|
|
|
|
Repository Repository
|
|
}
|
|
|
|
func (s *Service) Find(ctx context.Context, id int) (*Result, error) {
|
|
sprint, err := s.Repository.Find(ctx, s.Scopes.Context(ctx).ID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parts, err := s.Repository.ListParts(ctx, *sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.fill(ctx, *sprint, parts)
|
|
}
|
|
|
|
func (s *Service) ListBetween(ctx context.Context, from, to time.Time) ([]Result, error) {
|
|
allScopes, err := s.Scopes.Context(ctx).Scopes(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allSprints := make([][]entities.Sprint, len(allScopes))
|
|
|
|
eg, ctx2 := errgroup.WithContext(ctx)
|
|
for i, scope := range allScopes {
|
|
list := &allSprints[i]
|
|
scopeID := scope.ID
|
|
|
|
eg.Go(func() (err error) {
|
|
*list, err = s.Repository.ListBetween(ctx2, scopeID, from, to)
|
|
return
|
|
})
|
|
}
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sprints := genutils.Flatten2(allSprints)
|
|
sort.Slice(sprints, func(i, j int) bool {
|
|
si := sprints[i]
|
|
sj := sprints[j]
|
|
|
|
if si.FromTime.Equal(sj.FromTime) {
|
|
return si.Name < sj.Name
|
|
}
|
|
|
|
return si.FromTime.Before(sj.FromTime)
|
|
})
|
|
|
|
return s.fillMany(ctx, sprints)
|
|
}
|
|
|
|
func (s *Service) ListScopedBetween(ctx context.Context, from, to time.Time) ([]Result, error) {
|
|
sprints, err := s.Repository.ListBetween(ctx, s.Scopes.Context(ctx).ID, from, to)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.fillMany(ctx, sprints)
|
|
}
|
|
|
|
func (s *Service) fillMany(ctx context.Context, sprints []entities.Sprint) ([]Result, error) {
|
|
now := time.Now()
|
|
sort.Slice(sprints, func(i, j int) bool {
|
|
si := sprints[i]
|
|
sj := sprints[j]
|
|
|
|
if si.IsTimed != sj.IsTimed {
|
|
return si.IsTimed
|
|
}
|
|
|
|
siActive := si.ToTime.After(now)
|
|
sjActive := sj.ToTime.After(now)
|
|
if sjActive != siActive {
|
|
return siActive
|
|
}
|
|
|
|
if !si.FromTime.Equal(sj.FromTime) {
|
|
return si.FromTime.Before(sj.FromTime)
|
|
}
|
|
|
|
return si.ID < sj.ID
|
|
})
|
|
|
|
parts, err := s.Repository.ListParts(ctx, sprints...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
eg, ctx2 := errgroup.WithContext(ctx)
|
|
|
|
results := make([]Result, len(sprints))
|
|
for i := range sprints {
|
|
iCopy := i
|
|
|
|
eg.Go(func() error {
|
|
res, err := s.fill(ctx2, sprints[iCopy], parts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
results[iCopy] = *res
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (s *Service) Create(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
|
|
scope := s.Scopes.Context(ctx).Scope
|
|
|
|
sprint.Name = strings.Trim(sprint.Name, " \t\r\n")
|
|
if sprint.Name == "" {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "name",
|
|
Problem: "Empty name provided",
|
|
}
|
|
}
|
|
if !sprint.Kind.Valid() {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "kind",
|
|
Problem: "Non-existent kind ID",
|
|
Min: 0,
|
|
Max: models.MaxSprintKind,
|
|
}
|
|
}
|
|
if sprint.FromTime.IsZero() {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "fromTime",
|
|
Problem: "Empty from time provided",
|
|
}
|
|
}
|
|
if sprint.ToTime.IsZero() {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "toTime",
|
|
Problem: "Empty from time provided",
|
|
}
|
|
}
|
|
if sprint.ToTime.Before(sprint.FromTime) {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "toTime",
|
|
Problem: "Time goes only one way in this universe as fas as we know",
|
|
}
|
|
}
|
|
|
|
ids := genutils.Map(parts, func(p entities.SprintPart) int { return p.PartID })
|
|
|
|
switch sprint.Kind {
|
|
case models.SprintKindScope:
|
|
{
|
|
if len(parts) > 0 {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "parts",
|
|
Problem: "Scope-sprints cannot have parts.",
|
|
}
|
|
}
|
|
}
|
|
case models.SprintKindStats:
|
|
{
|
|
for _, part := range parts {
|
|
if !scope.HasStat(part.PartID) {
|
|
return nil, models.NotFoundIDError("Stat", part.PartID)
|
|
}
|
|
}
|
|
}
|
|
case models.SprintKindItems:
|
|
{
|
|
sprintItems, err := s.Items.ListScoped(ctx, models.ItemFilter{IDs: ids})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(sprintItems) != len(ids) {
|
|
for _, id := range ids {
|
|
if genutils.Find(sprintItems, func(i items.Result) bool { return i.ID == id }) == nil {
|
|
return nil, models.NotFoundIDError("Item", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(sprint.Tags) == 0 {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintInput",
|
|
Field: "tags",
|
|
Problem: "Item sprints cannot have tags.",
|
|
}
|
|
}
|
|
}
|
|
case models.SprintKindRequirements:
|
|
{
|
|
sprintReqs, err := s.Projects.FetchRequirements(ctx, ids...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(sprintReqs) != len(ids) {
|
|
for _, id := range ids {
|
|
if genutils.Find(sprintReqs, func(i projects.RequirementResult) bool { return i.ID == id }) == nil {
|
|
return nil, models.NotFoundIDError("Requirement", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sprint.ScopeID = scope.ID
|
|
|
|
newSprint, err := s.Repository.Insert(ctx, sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newParts := make([]entities.SprintPart, 0, 16)
|
|
for _, part := range parts {
|
|
part.SprintID = newSprint.ID
|
|
err = s.Repository.UpdatePart(ctx, part)
|
|
if err == nil {
|
|
newParts = append(newParts, part)
|
|
}
|
|
}
|
|
|
|
return s.fill(ctx, *newSprint, newParts)
|
|
}
|
|
|
|
func (s *Service) Update(ctx context.Context, id int, update models.SprintUpdate) (*Result, error) {
|
|
scope := s.Scopes.Context(ctx).Scope
|
|
sprint, err := s.Repository.Find(ctx, scope.ID, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if update.Name != nil && *update.Name == "" {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintUpdate",
|
|
Field: "name",
|
|
Problem: "Empty name provided",
|
|
}
|
|
}
|
|
fromTime := sprint.FromTime
|
|
if update.FromTime != nil {
|
|
fromTime = *update.FromTime
|
|
}
|
|
if update.ToTime != nil && update.ToTime.Before(fromTime) {
|
|
return nil, models.BadInputError{
|
|
Object: "SprintUpdate",
|
|
Field: "toTime",
|
|
Problem: "Time goes only one way in this universe as fas as we know",
|
|
}
|
|
}
|
|
|
|
err = s.Repository.Update(ctx, *sprint, update)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sprint.ApplyUpdate(update)
|
|
parts, err := s.Repository.ListParts(ctx, *sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.fill(ctx, *sprint, parts)
|
|
}
|
|
|
|
func (s *Service) AddPart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
|
|
scope := s.Scopes.Context(ctx).Scope
|
|
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch sprint.Kind {
|
|
case models.SprintKindItems:
|
|
{
|
|
_, err := s.Items.Find(ctx, sprintPart.PartID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
case models.SprintKindRequirements:
|
|
{
|
|
_, err := s.Projects.FindRequirement(ctx, sprintPart.PartID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
case models.SprintKindStats:
|
|
{
|
|
if !scope.HasStat(sprintPart.PartID) {
|
|
return nil, models.NotFoundIDError("Stat", sprintPart.PartID)
|
|
}
|
|
}
|
|
case models.SprintKindScope:
|
|
{
|
|
return nil, models.ForbiddenError("Scope-sprints cannot have parts")
|
|
}
|
|
}
|
|
|
|
err = s.Repository.UpdatePart(ctx, sprintPart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.fill(ctx, *sprint, sprintParts)
|
|
}
|
|
|
|
func (s *Service) RemovePart(ctx context.Context, sprintPart entities.SprintPart) (*Result, error) {
|
|
scope := s.Scopes.Context(ctx).Scope
|
|
sprint, err := s.Repository.Find(ctx, scope.ID, sprintPart.SprintID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.Repository.DeletePart(ctx, sprintPart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sprintParts, err := s.Repository.ListParts(ctx, *sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.fill(ctx, *sprint, sprintParts)
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, id int) (*Result, error) {
|
|
sprint, err := s.Find(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = s.Repository.Delete(ctx, sprint.Sprint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sprint, nil
|
|
}
|
|
|
|
func (s *Service) fill(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
|
|
res := Result{Sprint: sprint}
|
|
|
|
var allStats []scopes.ResultStat
|
|
sc := s.Scopes.Context(ctx)
|
|
if sc.ID != sprint.ScopeID {
|
|
allScopes, err := sc.Scopes(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, scope := range allScopes {
|
|
if scope.ID == sprint.ScopeID {
|
|
allStats = scope.Stats
|
|
break
|
|
}
|
|
}
|
|
if allStats == nil {
|
|
return nil, models.NotFoundError("Scope")
|
|
}
|
|
} else {
|
|
allStats = sc.Scope.Stats
|
|
}
|
|
|
|
partIDs := make([]int, 0, len(parts))
|
|
for _, part := range parts {
|
|
if part.SprintID == sprint.ID {
|
|
partIDs = append(partIDs, part.PartID)
|
|
}
|
|
}
|
|
|
|
progressMap := make(map[int]int, len(allStats))
|
|
res.Progress = make([]ResultProgress, 0, len(allStats))
|
|
res.PartIDs = partIDs
|
|
|
|
switch res.Kind {
|
|
case models.SprintKindItems:
|
|
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
|
IDs: partIDs,
|
|
ScopeIDs: []int{sprint.ScopeID},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Items = pickedItems
|
|
|
|
res.ItemsRequired = genutils.Ptr(len(pickedItems))
|
|
res.ItemsAcquired = new(int)
|
|
for _, item := range pickedItems {
|
|
if item.AcquiredTime != nil {
|
|
*res.ItemsAcquired += 1
|
|
}
|
|
}
|
|
|
|
for _, stat := range allStats {
|
|
totalRequired := 0
|
|
for _, item := range res.Items {
|
|
if item.AcquiredOutside(sprint.FromTime, sprint.ToTime) {
|
|
continue
|
|
}
|
|
|
|
if itemStat := item.Stat(stat.ID); itemStat != nil {
|
|
totalRequired += itemStat.Required
|
|
}
|
|
}
|
|
if totalRequired == 0 {
|
|
continue
|
|
}
|
|
|
|
progressMap[stat.ID] = len(res.Progress)
|
|
res.Progress = append(res.Progress, ResultProgress{
|
|
ID: stat.ID,
|
|
Name: stat.Name,
|
|
Weight: stat.Weight,
|
|
Required: &totalRequired,
|
|
})
|
|
}
|
|
case models.SprintKindRequirements:
|
|
includedRequirements, err := s.Projects.FetchRequirements(ctx, partIDs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Requirements = includedRequirements
|
|
|
|
for _, req := range res.Requirements {
|
|
for _, item := range req.Items {
|
|
disqualified := false
|
|
for _, tag := range sprint.Tags {
|
|
if !item.HasTag(tag) {
|
|
disqualified = true
|
|
break
|
|
}
|
|
}
|
|
if !disqualified {
|
|
res.Items = append(res.Items, item)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, stat := range allStats {
|
|
totalRequired := 0
|
|
for _, req := range res.Requirements {
|
|
if reqStat := req.Stat(stat.ID); reqStat != nil {
|
|
totalRequired += reqStat.Required
|
|
}
|
|
}
|
|
if totalRequired == 0 {
|
|
continue
|
|
}
|
|
|
|
progressMap[stat.ID] = len(res.Progress)
|
|
res.Progress = append(res.Progress, ResultProgress{
|
|
ID: stat.ID,
|
|
Name: stat.Name,
|
|
Weight: stat.Weight,
|
|
Required: &totalRequired,
|
|
})
|
|
}
|
|
|
|
case models.SprintKindStats:
|
|
includedStats := make([]scopes.ResultStat, 0, len(partIDs))
|
|
for _, stat := range allStats {
|
|
for _, id := range partIDs {
|
|
if stat.ID == id {
|
|
includedStats = append(includedStats, stat)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
progressMap = make(map[int]int, len(includedStats))
|
|
res.Progress = make([]ResultProgress, 0, len(includedStats))
|
|
for _, stat := range includedStats {
|
|
required := 0
|
|
for _, part := range parts {
|
|
if part.PartID == stat.ID {
|
|
required = part.Required
|
|
break
|
|
}
|
|
}
|
|
|
|
progressMap[stat.ID] = len(res.Progress)
|
|
res.Progress = append(res.Progress, ResultProgress{
|
|
ID: stat.ID,
|
|
Name: stat.Name,
|
|
Weight: stat.Weight,
|
|
Required: &required,
|
|
})
|
|
}
|
|
|
|
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
|
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime},
|
|
StatIDs: partIDs,
|
|
ScopeIDs: []int{sprint.ScopeID},
|
|
Tags: sprint.Tags,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Items = acquiredItems
|
|
|
|
case models.SprintKindScope:
|
|
acquiredItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
|
|
AcquiredTime: &models.TimeInterval[time.Time]{Min: sprint.FromTime, Max: sprint.ToTime},
|
|
ScopeIDs: []int{sprint.ScopeID},
|
|
Tags: sprint.Tags,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res.Items = acquiredItems
|
|
|
|
for _, stat := range allStats {
|
|
progressMap[stat.ID] = len(res.Progress)
|
|
res.Progress = append(res.Progress, ResultProgress{
|
|
ID: stat.ID,
|
|
Name: stat.Name,
|
|
Weight: stat.Weight,
|
|
Required: genutils.Ptr(0),
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(res.Items, func(i, j int) bool {
|
|
ii := res.Items[i]
|
|
ij := res.Items[j]
|
|
|
|
if ii.AcquiredTime == nil && ij.AcquiredTime == nil {
|
|
return res.Items[i].CreatedTime.Before(res.Items[j].CreatedTime)
|
|
} else if ii.AcquiredTime == nil && ij.AcquiredTime != nil {
|
|
return true
|
|
} else if ii.AcquiredTime != nil && ij.AcquiredTime == nil {
|
|
return false
|
|
} else {
|
|
return ii.AcquiredTime.Before(*ij.AcquiredTime)
|
|
}
|
|
})
|
|
|
|
// Measure progress
|
|
itemBurndown := burndownGenerator{}
|
|
aggregateBurndown := burndownGenerator{}
|
|
for _, item := range res.Items {
|
|
if !item.AcquiredBetween(sprint.FromTime, sprint.ToTime) {
|
|
continue
|
|
}
|
|
|
|
itemBurndown.Add(*item.AcquiredTime, 1)
|
|
|
|
itemAggregate := 0.0
|
|
for _, stat := range item.Stats {
|
|
if pi, ok := progressMap[stat.ID]; ok {
|
|
res.Progress[pi].Acquired += stat.Acquired
|
|
|
|
if res.IsUnweighted {
|
|
itemAggregate += float64(stat.Acquired)
|
|
} else {
|
|
itemAggregate += float64(stat.Acquired) * stat.Weight
|
|
}
|
|
}
|
|
}
|
|
|
|
aggregateBurndown.Add(*item.AcquiredTime, itemAggregate)
|
|
}
|
|
res.AggregateBurndown = aggregateBurndown.Points
|
|
res.ItemBurndown = itemBurndown.Points
|
|
|
|
// For requirement sprints, remove items array after enumeration
|
|
if res.Kind == models.SprintKindRequirements {
|
|
res.Items = nil
|
|
}
|
|
|
|
// Calculate aggregate values
|
|
aggregateAcquired := 0.0
|
|
aggregatePlanned := 0.0
|
|
for _, progress := range res.Progress {
|
|
if res.IsUnweighted {
|
|
aggregateAcquired += float64(progress.Acquired)
|
|
if progress.Required != nil {
|
|
aggregatePlanned += float64(*progress.Required)
|
|
}
|
|
} else {
|
|
aggregateAcquired += float64(progress.Acquired) * progress.Weight
|
|
if progress.Required != nil {
|
|
aggregatePlanned += float64(*progress.Required) * progress.Weight
|
|
}
|
|
}
|
|
}
|
|
res.AggregateAcquired = aggregateAcquired
|
|
res.AggregatePlanned = aggregatePlanned
|
|
|
|
if res.AggregateRequired < 0 {
|
|
res.AggregateRequired = int(res.AggregatePlanned)
|
|
}
|
|
|
|
return &res, nil
|
|
}
|