Browse Source

sprints do be sprinting.

master
Gisle Aune 2 years ago
parent
commit
33231a96d2
  1. 8
      cmd/stufflog3-local/main.go
  2. 1
      go.mod
  3. 2
      go.sum
  4. 10
      internal/genutils/map.go
  5. 4
      models/generics.go
  6. 19
      models/item.go
  7. 9
      models/sprint.go
  8. 41
      ports/httpapi/common.go
  9. 12
      ports/httpapi/items.go
  10. 47
      ports/httpapi/sprints.go
  11. 10
      ports/mysql/db.go
  12. 11
      ports/mysql/items.go
  13. 30
      ports/mysql/mysqlcore/db.go
  14. 57
      ports/mysql/mysqlcore/sprint.sql.go
  15. 65
      ports/mysql/projects.go
  16. 11
      ports/mysql/queries/sprint.sql
  17. 111
      ports/mysql/sprint.go
  18. 10
      usecases/items/result.go
  19. 1
      usecases/projects/repository.go
  20. 93
      usecases/projects/result.go
  21. 33
      usecases/projects/service.go
  22. 3
      usecases/sprints/repository.go
  23. 27
      usecases/sprints/result.go
  24. 249
      usecases/sprints/service.go

8
cmd/stufflog3-local/main.go

@ -7,6 +7,7 @@ import (
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
"github.com/gin-gonic/gin"
"log"
@ -50,6 +51,12 @@ func main() {
Items: itemsService,
Repository: db.Projects(),
}
sprintsService := &sprints.Service{
Scopes: scopesService,
Items: itemsService,
Projects: projectsService,
Repository: db.Sprints(),
}
server := gin.New()
apiV1 := server.Group("/api/v1")
@ -65,6 +72,7 @@ func main() {
apiV1ScopesSub.Use(httpapi.ScopeMiddleware(scopesService, authService))
httpapi.Projects(apiV1ScopesSub.Group("/projects"), projectsService)
httpapi.Items(apiV1ScopesSub.Group("/items"), itemsService)
httpapi.Sprints(apiV1ScopesSub.Group("/sprints"), sprintsService)
exitSignal := make(chan os.Signal)
signal.Notify(exitSignal, os.Interrupt, os.Kill, syscall.SIGTERM)

1
go.mod

@ -23,6 +23,7 @@ require (
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
github.com/ugorji/go/codec v1.1.7 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 // indirect
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 // indirect
gopkg.in/yaml.v2 v2.2.8 // indirect
)

2
go.sum

@ -48,6 +48,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4=
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=

10
internal/genutils/map.go

@ -0,0 +1,10 @@
package genutils
func Map[I any, O any](arr []I, cb func(a I) O) []O {
res := make([]O, 0, len(arr))
for _, value := range arr {
res = append(res, cb(value))
}
return res
}

4
models/generics.go

@ -5,11 +5,11 @@ type betweenContract[T any] interface {
IsZero() bool
}
type Between[T betweenContract[T]] struct {
type TimeInterval[T betweenContract[T]] struct {
Min T `json:"min"`
Max T `json:"max"`
}
func (b *Between[T]) Valid() bool {
func (b *TimeInterval[T]) Valid() bool {
return !b.Min.IsZero() && !b.Max.IsZero() && b.Min.Before(b.Max)
}

19
models/item.go

@ -15,13 +15,14 @@ type ItemUpdate struct {
}
type ItemFilter struct {
OwnerID *string `json:"ownerId,omitempty"`
AcquiredTime *Between[time.Time] `json:"acquiredTime,omitempty"`
CreatedTime *Between[time.Time] `json:"createdTime,omitempty"`
ScheduledDate *Between[Date] `json:"scheduledDate,omitempty"`
ScopeIDs []int `json:"scopeIds,omitempty"`
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`
StatIDs []int `json:"statIds,omitempty"`
Loose bool `json:"loose,omitempty"`
OwnerID *string `json:"ownerId,omitempty"`
AcquiredTime *TimeInterval[time.Time] `json:"acquiredTime,omitempty"`
CreatedTime *TimeInterval[time.Time] `json:"createdTime,omitempty"`
ScheduledDate *TimeInterval[Date] `json:"scheduledDate,omitempty"`
IDs []int `json:"ids"`
ScopeIDs []int `json:"scopeIds,omitempty"`
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`
StatIDs []int `json:"statIds,omitempty"`
Loose bool `json:"loose,omitempty"`
}

9
models/sprint.go

@ -13,11 +13,12 @@ type SprintUpdate struct {
AggregateRequired *int `json:"aggregateRequired"`
}
// SprintKind decides the composition of stat bars (SB) and what objects are included in the result (R)
type SprintKind int
const (
SprintKindItems SprintKind = iota
SprintKindRequirements
SprintKindStats
SprintKindScope
SprintKindItems SprintKind = iota // SB: items' total required, R: items
SprintKindRequirements // SB: requirements' total required, R: requirements
SprintKindStats // SB: parts' total required, R: items(AcquiredDate!=nil), stats
SprintKindScope // SB: no required, R: items(AcquiredDate!=nil)
)

41
ports/httpapi/common.go

@ -1,6 +1,7 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"git.aiterp.net/stufflog3/stufflog3/models"
@ -13,6 +14,46 @@ type statusError interface {
HttpStatus() (int, string, interface{})
}
func processorHandler[I any, O any](key, idKey string, callback func(ctx context.Context, id int, input I) (O, error)) gin.HandlerFunc {
return handler(key, func(c *gin.Context) (interface{}, error) {
input := new(I)
err := c.BindJSON(input)
if err != nil {
return nil, models.BadInputError{
Object: "body",
Field: "*",
Problem: "Bad JSON input:" + err.Error(),
}
}
id := 0
if idKey != "" {
id, err = reqInt(c, idKey)
if err != nil {
return nil, err
}
}
return callback(c.Request.Context(), id, *input)
})
}
func getterHandler[O any](key, idKey string, callback func(ctx context.Context, id int) (O, error)) gin.HandlerFunc {
return handler(key, func(c *gin.Context) (interface{}, error) {
id := 0
if idKey != "" {
parsed, err := reqInt(c, idKey)
if err != nil {
return nil, err
}
id = parsed
}
return callback(c.Request.Context(), id)
})
}
func handler(key string, callback func(c *gin.Context) (interface{}, error)) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := callback(c)

12
ports/httpapi/items.go

@ -38,7 +38,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}
}
filter.ScheduledDate = &models.Between[models.Date]{Min: min, Max: max}
filter.ScheduledDate = &models.TimeInterval[models.Date]{Min: min, Max: max}
}
queryCreatedMin := c.Query("createdMin")
@ -61,7 +61,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}
}
filter.CreatedTime = &models.Between[time.Time]{Min: min, Max: max}
filter.CreatedTime = &models.TimeInterval[time.Time]{Min: min, Max: max}
}
queryAcquiredMin := c.Query("acquiredMin")
@ -84,7 +84,7 @@ func Items(g *gin.RouterGroup, items *items.Service) {
}
}
filter.AcquiredTime = &models.Between[time.Time]{Min: min, Max: max}
filter.AcquiredTime = &models.TimeInterval[time.Time]{Min: min, Max: max}
}
anyDateMin := c.Query("anyDateMin")
@ -110,9 +110,9 @@ func Items(g *gin.RouterGroup, items *items.Service) {
minY, minM, minD := min.Date()
maxY, maxM, maxD := max.Date()
filter.AcquiredTime = &models.Between[time.Time]{Min: min, Max: max}
filter.CreatedTime = &models.Between[time.Time]{Min: min, Max: max}
filter.ScheduledDate = &models.Between[models.Date]{
filter.AcquiredTime = &models.TimeInterval[time.Time]{Min: min, Max: max}
filter.CreatedTime = &models.TimeInterval[time.Time]{Min: min, Max: max}
filter.ScheduledDate = &models.TimeInterval[models.Date]{
Min: models.Date{minY, int(minM), minD},
Max: models.Date{maxY, int(maxM), maxD},
}

47
ports/httpapi/sprints.go

@ -0,0 +1,47 @@
package httpapi
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints"
"github.com/gin-gonic/gin"
"time"
)
func Sprints(g *gin.RouterGroup, sprintsService *sprints.Service) {
g.GET("", handler("sprints", func(c *gin.Context) (interface{}, error) {
from := time.Now().Add(-time.Hour * 24 * 7)
if fromQuery := c.Query("from"); fromQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, fromQuery)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "from",
Problem: "Incorrect date format: " + err.Error(),
}
}
from = parsed
}
to := time.Now().Add(time.Hour * 24 * 7)
if toQuery := c.Query("to"); toQuery != "" {
parsed, err := time.Parse(time.RFC3339Nano, toQuery)
if err != nil {
return nil, models.BadInputError{
Object: "Query",
Field: "to",
Problem: "Incorrect date format: " + err.Error(),
}
}
to = parsed
}
return sprintsService.ListScopedBetween(c.Request.Context(), from, to)
}))
g.GET("/:sprint_id", getterHandler("sprint", "sprint_id", func(ctx context.Context, id int) (*sprints.Result, error) {
return sprintsService.Find(ctx, id)
}))
}

10
ports/mysql/db.go

@ -11,6 +11,7 @@ import (
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
"git.aiterp.net/stufflog3/stufflog3/usecases/sprints"
"git.aiterp.net/stufflog3/stufflog3/usecases/stats"
"time"
@ -50,6 +51,13 @@ func (db *Database) Items() items.Repository {
}
}
func (db *Database) Sprints() sprints.Repository {
return &sprintRepository{
db: db.db,
q: db.q,
}
}
func Connect(host string, port int, username, password, database string) (*Database, error) {
db, err := sql.Open("mysql", fmt.Sprintf(
"%s:%s@(%s:%d)/%s?parseTime=true", username, password, host, port, database,
@ -124,4 +132,4 @@ func sqlJsonPtr(ptr interface{}) sqltypes.NullRawMessage {
} else {
return sqltypes.NullRawMessage{Valid: false}
}
}
}

11
ports/mysql/items.go

@ -38,6 +38,9 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([]entities.Item, error) {
// Blank arrays are not the same as nulls
if filter.IDs != nil && len(filter.IDs) == 0 {
return []entities.Item{}, nil
}
if filter.ScopeIDs != nil && len(filter.ScopeIDs) == 0 {
return []entities.Item{}, nil
}
@ -79,6 +82,9 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
sq = sq.Where(dateOr)
}
if len(filter.IDs) > 0 {
sq = sq.Where(squirrel.Eq{"i.id": filter.IDs})
}
if len(filter.ScopeIDs) > 0 {
sq = sq.Where(squirrel.Eq{"i.scope_id": filter.ScopeIDs})
}
@ -113,6 +119,7 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
return nil, err
}
seen := make(map[int]bool, 32)
res := make([]entities.Item, 0, 32)
for rows.Next() {
item := entities.Item{}
@ -140,6 +147,10 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
return nil, err
}
if seen[item.ID] {
continue
}
seen[item.ID] = true
item.ProjectRequirementID = intPtr(projectRequirementId)
item.ProjectID = intPtr(projectId)

30
ports/mysql/mysqlcore/db.go

@ -93,6 +93,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.deleteSprintStmt, err = db.PrepareContext(ctx, deleteSprint); err != nil {
return nil, fmt.Errorf("error preparing query DeleteSprint: %w", err)
}
if q.deleteSprintPartStmt, err = db.PrepareContext(ctx, deleteSprintPart); err != nil {
return nil, fmt.Errorf("error preparing query DeleteSprintPart: %w", err)
}
if q.deleteStatStmt, err = db.PrepareContext(ctx, deleteStat); err != nil {
return nil, fmt.Errorf("error preparing query DeleteStat: %w", err)
}
@ -183,6 +186,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listScopesByUserStmt, err = db.PrepareContext(ctx, listScopesByUser); err != nil {
return nil, fmt.Errorf("error preparing query ListScopesByUser: %w", err)
}
if q.listSprintPartsStmt, err = db.PrepareContext(ctx, listSprintParts); err != nil {
return nil, fmt.Errorf("error preparing query ListSprintParts: %w", err)
}
if q.listSprintsAtStmt, err = db.PrepareContext(ctx, listSprintsAt); err != nil {
return nil, fmt.Errorf("error preparing query ListSprintsAt: %w", err)
}
@ -201,6 +207,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.replaceScopeMemberStmt, err = db.PrepareContext(ctx, replaceScopeMember); err != nil {
return nil, fmt.Errorf("error preparing query ReplaceScopeMember: %w", err)
}
if q.replaceSprintPartStmt, err = db.PrepareContext(ctx, replaceSprintPart); err != nil {
return nil, fmt.Errorf("error preparing query ReplaceSprintPart: %w", err)
}
if q.updateItemStmt, err = db.PrepareContext(ctx, updateItem); err != nil {
return nil, fmt.Errorf("error preparing query UpdateItem: %w", err)
}
@ -339,6 +348,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing deleteSprintStmt: %w", cerr)
}
}
if q.deleteSprintPartStmt != nil {
if cerr := q.deleteSprintPartStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteSprintPartStmt: %w", cerr)
}
}
if q.deleteStatStmt != nil {
if cerr := q.deleteStatStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteStatStmt: %w", cerr)
@ -489,6 +503,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listScopesByUserStmt: %w", cerr)
}
}
if q.listSprintPartsStmt != nil {
if cerr := q.listSprintPartsStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listSprintPartsStmt: %w", cerr)
}
}
if q.listSprintsAtStmt != nil {
if cerr := q.listSprintsAtStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listSprintsAtStmt: %w", cerr)
@ -519,6 +538,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing replaceScopeMemberStmt: %w", cerr)
}
}
if q.replaceSprintPartStmt != nil {
if cerr := q.replaceSprintPartStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing replaceSprintPartStmt: %w", cerr)
}
}
if q.updateItemStmt != nil {
if cerr := q.updateItemStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing updateItemStmt: %w", cerr)
@ -611,6 +635,7 @@ type Queries struct {
deleteScopeStmt *sql.Stmt
deleteScopeMemberStmt *sql.Stmt
deleteSprintStmt *sql.Stmt
deleteSprintPartStmt *sql.Stmt
deleteStatStmt *sql.Stmt
getItemStmt *sql.Stmt
getItemStatProgressBetweenStmt *sql.Stmt
@ -641,12 +666,14 @@ type Queries struct {
listScopeMembersMultiStmt *sql.Stmt
listScopesStmt *sql.Stmt
listScopesByUserStmt *sql.Stmt
listSprintPartsStmt *sql.Stmt
listSprintsAtStmt *sql.Stmt
listSprintsBetweenStmt *sql.Stmt
listStatsStmt *sql.Stmt
replaceItemStatProgressStmt *sql.Stmt
replaceProjectRequirementStatStmt *sql.Stmt
replaceScopeMemberStmt *sql.Stmt
replaceSprintPartStmt *sql.Stmt
updateItemStmt *sql.Stmt
updateProjectStmt *sql.Stmt
updateProjectRequirementStmt *sql.Stmt
@ -682,6 +709,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
deleteScopeStmt: q.deleteScopeStmt,
deleteScopeMemberStmt: q.deleteScopeMemberStmt,
deleteSprintStmt: q.deleteSprintStmt,
deleteSprintPartStmt: q.deleteSprintPartStmt,
deleteStatStmt: q.deleteStatStmt,
getItemStmt: q.getItemStmt,
getItemStatProgressBetweenStmt: q.getItemStatProgressBetweenStmt,
@ -712,12 +740,14 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listScopeMembersMultiStmt: q.listScopeMembersMultiStmt,
listScopesStmt: q.listScopesStmt,
listScopesByUserStmt: q.listScopesByUserStmt,
listSprintPartsStmt: q.listSprintPartsStmt,
listSprintsAtStmt: q.listSprintsAtStmt,
listSprintsBetweenStmt: q.listSprintsBetweenStmt,
listStatsStmt: q.listStatsStmt,
replaceItemStatProgressStmt: q.replaceItemStatProgressStmt,
replaceProjectRequirementStatStmt: q.replaceProjectRequirementStatStmt,
replaceScopeMemberStmt: q.replaceScopeMemberStmt,
replaceSprintPartStmt: q.replaceSprintPartStmt,
updateItemStmt: q.updateItemStmt,
updateProjectStmt: q.updateProjectStmt,
updateProjectRequirementStmt: q.updateProjectRequirementStmt,

57
ports/mysql/mysqlcore/sprint.sql.go

@ -29,6 +29,20 @@ func (q *Queries) DeleteSprint(ctx context.Context, id int) error {
return err
}
const deleteSprintPart = `-- name: DeleteSprintPart :exec
DELETE FROM sprint_part WHERE sprint_id = ? AND object_id = ?
`
type DeleteSprintPartParams struct {
SprintID int
ObjectID int
}
func (q *Queries) DeleteSprintPart(ctx context.Context, arg DeleteSprintPartParams) error {
_, err := q.exec(ctx, q.deleteSprintPartStmt, deleteSprintPart, arg.SprintID, arg.ObjectID)
return err
}
const getSprint = `-- name: GetSprint :one
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint WHERE id = ? AND scope_id = ?
`
@ -95,6 +109,33 @@ func (q *Queries) InsertSprint(ctx context.Context, arg InsertSprintParams) (sql
)
}
const listSprintParts = `-- name: ListSprintParts :many
SELECT sprint_id, object_id, required FROM sprint_part WHERE sprint_id = ?
`
func (q *Queries) ListSprintParts(ctx context.Context, sprintID int) ([]SprintPart, error) {
rows, err := q.query(ctx, q.listSprintPartsStmt, listSprintParts, sprintID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SprintPart{}
for rows.Next() {
var i SprintPart
if err := rows.Scan(&i.SprintID, &i.ObjectID, &i.Required); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listSprintsAt = `-- name: ListSprintsAt :many
SELECT id, scope_id, name, description, from_time, to_time, is_timed, is_coarse, is_unweighted, kind, aggregate_name, aggregate_required FROM sprint WHERE scope_id = ? AND from_time <= ? AND to_time > ?
`
@ -206,6 +247,22 @@ func (q *Queries) ListSprintsBetween(ctx context.Context, arg ListSprintsBetween
return items, nil
}
const replaceSprintPart = `-- name: ReplaceSprintPart :exec
REPLACE INTO sprint_part (sprint_id, object_id, required)
VALUES (?, ?, ?)
`
type ReplaceSprintPartParams struct {
SprintID int
ObjectID int
Required int
}
func (q *Queries) ReplaceSprintPart(ctx context.Context, arg ReplaceSprintPartParams) error {
_, err := q.exec(ctx, q.replaceSprintPartStmt, replaceSprintPart, arg.SprintID, arg.ObjectID, arg.Required)
return err
}
const updateSprint = `-- name: UpdateSprint :exec
UPDATE sprint
SET name = ?,

65
ports/mysql/projects.go

@ -6,6 +6,7 @@ import (
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"github.com/Masterminds/squirrel"
)
type projectRepository struct {
@ -133,6 +134,70 @@ func (r *projectRepository) Delete(ctx context.Context, project entities.Project
return tx.Commit()
}
func (r *projectRepository) FetchRequirements(ctx context.Context, scopeID int, requirementIDs ...int) ([]entities.Requirement, []entities.RequirementStat, error) {
if len(requirementIDs) == 0 {
return []entities.Requirement{}, []entities.RequirementStat{}, nil
}
query, args, err := squirrel.Select("id, scope_id, project_id, name, status, description").
From("project_requirement").
Where(squirrel.Eq{"scope_id": scopeID}).
Where(squirrel.Eq{"id": requirementIDs}).
ToSql()
if err != nil {
return nil, nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, nil, err
}
ids := make([]int, 0, 16)
requirements := make([]entities.Requirement, 0, len(requirementIDs))
for rows.Next() {
requirement := entities.Requirement{}
if err := rows.Scan(
&requirement.ID,
&requirement.ScopeID,
&requirement.ProjectID,
&requirement.Name,
&requirement.Status,
&requirement.Description,
); err != nil {
return nil, nil, err
}
requirements = append(requirements, requirement)
ids = append(ids, requirement.ID)
}
query, args, err = squirrel.Select("project_requirement_id, stat_id, required").
From("project_requirement_stat").
Where(squirrel.Eq{"project_requirement_id": requirementIDs}).
ToSql()
if err != nil {
return nil, nil, err
}
rows, err = r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, nil, err
}
stats := make([]entities.RequirementStat, 0, len(requirementIDs))
for rows.Next() {
stat := entities.RequirementStat{}
if err := rows.Scan(
&stat.RequirementID,
&stat.StatID,
&stat.Required,
); err != nil {
return nil, nil, err
}
stats = append(stats, stat)
}
return requirements, stats, nil
}
func (r *projectRepository) ListRequirements(ctx context.Context, projectID int) ([]entities.Requirement, []entities.RequirementStat, error) {
reqRows, err := r.q.ListProjectRequirements(ctx, projectID)
if err != nil && err != sql.ErrNoRows {

11
ports/mysql/queries/sprint.sql

@ -12,6 +12,7 @@ AND (
OR (from_time < ? AND to_time >= ?)
);
-- name: GetSprint :one
SELECT * FROM sprint WHERE id = ? AND scope_id = ?;
@ -38,3 +39,13 @@ DELETE FROM sprint WHERE id = ?;
-- name: DeleteAllScopeSprints :exec
DELETE FROM sprint WHERE scope_id = ?;
-- name: ListSprintParts :many
SELECT * FROM sprint_part WHERE sprint_id = ?;
-- name: ReplaceSprintPart :exec
REPLACE INTO sprint_part (sprint_id, object_id, required)
VALUES (?, ?, ?);
-- name: DeleteSprintPart :exec
DELETE FROM sprint_part WHERE sprint_id = ? AND object_id = ?;

111
ports/mysql/sprint.go

@ -4,8 +4,10 @@ import (
"context"
"database/sql"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"github.com/Masterminds/squirrel"
"time"
)
@ -111,16 +113,113 @@ func (r *sprintRepository) ListBetween(ctx context.Context, scopeID int, from, t
}
func (r *sprintRepository) Create(ctx context.Context, sprint entities.Sprint) (*entities.Sprint, error) {
//TODO implement me
panic("implement me")
res, err := r.q.InsertSprint(ctx, mysqlcore.InsertSprintParams{
ScopeID: sprint.ScopeID,
Name: sprint.Name,
Description: sprint.Description,
Kind: int(sprint.Kind),
FromTime: sprint.FromTime,
ToTime: sprint.ToTime,
IsTimed: sprint.IsTimed,
IsCoarse: sprint.IsCoarse,
AggregateName: sprint.AggregateName,
AggregateRequired: sprint.AggregateRequired,
IsUnweighted: sprint.IsUnweighted,
})
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
sprint.ID = int(id)
return &sprint, nil
}
func (r *sprintRepository) Update(ctx context.Context, sprint entities.Sprint, update models.SprintUpdate) error {
//TODO implement me
panic("implement me")
sprint.ApplyUpdate(update)
return r.q.UpdateSprint(ctx, mysqlcore.UpdateSprintParams{
Name: sprint.Name,
Description: sprint.Description,
FromTime: sprint.FromTime,
ToTime: sprint.ToTime,
IsTimed: sprint.IsTimed,
IsCoarse: sprint.IsCoarse,
AggregateName: sprint.AggregateName,
AggregateRequired: sprint.AggregateRequired,
ID: sprint.ID,
})
}
func (r *sprintRepository) Delete(ctx context.Context, sprint entities.Sprint) error {
//TODO implement me
panic("implement me")
return r.q.DeleteSprint(ctx, sprint.ID)
}
func (r *sprintRepository) ListParts(ctx context.Context, sprints ...entities.Sprint) ([]entities.SprintPart, error) {
if len(sprints) == 0 {
return []entities.SprintPart{}, nil
} else if len(sprints) == 1 {
rows, err := r.q.ListSprintParts(ctx, sprints[0].ID)
if err != nil {
return nil, err
}
return genutils.Map(rows, func(row mysqlcore.SprintPart) entities.SprintPart {
return entities.SprintPart{
SprintID: row.SprintID,
PartID: row.ObjectID,
Required: row.Required,
}
}), nil
}
ids := make([]int, 0, len(sprints))
for _, sprint := range sprints {
ids = append(ids, sprint.ID)
}
query, args, err := squirrel.Select("sprint_id, object_id, required").
From("sprint_part").
Where(squirrel.Eq{"sprint_id": ids}).
ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
res := make([]entities.SprintPart, 0, 16)
for rows.Next() {
part := entities.SprintPart{}
err = rows.Scan(&part.SprintID, &part.PartID, &part.Required)
if err != nil {
return nil, err
}
res = append(res, part)
}
return res, nil
}
func (r *sprintRepository) UpdatePart(ctx context.Context, part entities.SprintPart) error {
return r.q.ReplaceSprintPart(ctx, mysqlcore.ReplaceSprintPartParams{
SprintID: part.SprintID,
ObjectID: part.PartID,
Required: part.Required,
})
}
func (r *sprintRepository) DeletePart(ctx context.Context, part entities.SprintPart) error {
return r.q.DeleteSprintPart(ctx, mysqlcore.DeleteSprintPartParams{
SprintID: part.SprintID,
ObjectID: part.PartID,
})
}

10
usecases/items/result.go

@ -8,6 +8,16 @@ type Result struct {
Stats []ResultStat `json:"stats"`
}
func (r *Result) Stat(statID int) *ResultStat {
for _, stat := range r.Stats {
if stat.ID == statID {
return &stat
}
}
return nil
}
type ResultStat struct {
ID int `json:"id"`
Name string `json:"name"`

1
usecases/projects/repository.go

@ -12,6 +12,7 @@ type Repository interface {
Create(ctx context.Context, project entities.Project) (*entities.Project, error)
Update(ctx context.Context, project entities.Project, update models.ProjectUpdate) error
Delete(ctx context.Context, project entities.Project) error
FetchRequirements(ctx context.Context, scopeID int, requirementIDs ...int) ([]entities.Requirement, []entities.RequirementStat, error)
ListRequirements(ctx context.Context, projectID int) ([]entities.Requirement, []entities.RequirementStat, error)
CreateRequirement(ctx context.Context, requirement entities.Requirement) (*entities.Requirement, error)
UpdateRequirement(ctx context.Context, requirement entities.Requirement, update models.RequirementUpdate) error

93
usecases/projects/result.go

@ -39,6 +39,16 @@ type RequirementResult struct {
Items []items.Result `json:"items"`
}
func (r *RequirementResult) Stat(id int) *RequirementResultStat {
for _, stat := range r.Stats {
if stat.ID == id {
return &stat
}
}
return nil
}
type RequirementResultStat struct {
ID int `json:"id"`
Name string `json:"name"`
@ -65,53 +75,58 @@ func generateResult(
continue
}
resReq := RequirementResult{
ID: req.ID,
Name: req.Name,
Description: req.Description,
Status: req.Status,
Stats: make([]RequirementResultStat, 0, 8),
Items: make([]items.Result, 0, 8),
}
statIndices := make(map[int]int)
for _, reqStat := range requirementStats {
if reqStat.RequirementID != req.ID {
continue
}
resReq := generateRequirementResult(req, requirementStats, stats, projectItems)
resStat := RequirementResultStat{
ID: reqStat.StatID,
Required: reqStat.Required,
}
for _, stat := range stats {
if stat.ID == resStat.ID {
resStat.Name = stat.Name
resStat.Weight = stat.Weight
break
}
}
res.Requirements = append(res.Requirements, resReq)
}
return res
}
resReq.Stats = append(resReq.Stats, resStat)
statIndices[reqStat.StatID] = len(resReq.Stats) - 1
func generateRequirementResult(req entities.Requirement, requirementStats []entities.RequirementStat, stats []entities.Stat, projectItems []items.Result) RequirementResult {
resReq := RequirementResult{
ID: req.ID,
Name: req.Name,
Description: req.Description,
Status: req.Status,
Stats: make([]RequirementResultStat, 0, 8),
Items: make([]items.Result, 0, 8),
}
statIndices := make(map[int]int)
for _, reqStat := range requirementStats {
if reqStat.RequirementID != req.ID {
continue
}
for _, item := range projectItems {
if item.ProjectRequirementID == nil || *item.ProjectRequirementID != req.ID {
continue
resStat := RequirementResultStat{
ID: reqStat.StatID,
Required: reqStat.Required,
}
for _, stat := range stats {
if stat.ID == resStat.ID {
resStat.Name = stat.Name
resStat.Weight = stat.Weight
break
}
}
for _, stat := range item.Stats {
if statIndex, ok := statIndices[stat.ID]; ok {
resReq.Stats[statIndex].Acquired += stat.Acquired
resReq.Stats[statIndex].Planned += stat.Required
}
}
resReq.Stats = append(resReq.Stats, resStat)
statIndices[reqStat.StatID] = len(resReq.Stats) - 1
}
resReq.Items = append(resReq.Items, item)
for _, item := range projectItems {
if item.ProjectRequirementID == nil || *item.ProjectRequirementID != req.ID {
continue
}
res.Requirements = append(res.Requirements, resReq)
}
for _, stat := range item.Stats {
if statIndex, ok := statIndices[stat.ID]; ok {
resReq.Stats[statIndex].Acquired += stat.Acquired
resReq.Stats[statIndex].Planned += stat.Required
}
}
return res
resReq.Items = append(resReq.Items, item)
}
return resReq
}

33
usecases/projects/service.go

@ -65,3 +65,36 @@ func (s *Service) List(ctx context.Context) ([]Entry, error) {
return entries, nil
}
func (s *Service) FetchRequirements(ctx context.Context, ids ...int) ([]RequirementResult, error) {
sc := s.Scopes.Context(ctx)
requirements, requirementStats, err := s.Repository.FetchRequirements(ctx, sc.ID, ids...)
if err != nil {
return nil, err
}
items, err := s.Items.ListScoped(ctx, models.ItemFilter{
RequirementIDs: ids,
})
if err != nil {
return nil, err
}
stats, err := sc.Stats(ctx)
if err != nil {
return nil, err
}
results := make([]RequirementResult, 0, len(requirements))
for _, req := range requirements {
results = append(results, generateRequirementResult(
req,
requirementStats,
stats,
items,
))
}
return results, nil
}

3
usecases/sprints/repository.go

@ -14,4 +14,7 @@ type Repository interface {
Create(ctx context.Context, sprint entities.Sprint) (*entities.Sprint, error)
Update(ctx context.Context, sprint entities.Sprint, update models.SprintUpdate) error
Delete(ctx context.Context, sprint entities.Sprint) error
ListParts(ctx context.Context, sprints ...entities.Sprint) ([]entities.SprintPart, error)
UpdatePart(ctx context.Context, part entities.SprintPart) error
DeletePart(ctx context.Context, part entities.SprintPart) error
}

27
usecases/sprints/result.go

@ -0,0 +1,27 @@
package sprints
import (
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/usecases/items"
"git.aiterp.net/stufflog3/stufflog3/usecases/projects"
)
type Result struct {
entities.Sprint
AggregateAcquired int `json:"aggregateAcquired"`
AggregateTotal int `json:"aggregateTotal"`
Items []items.Result `json:"items,omitempty"`
Stats []entities.Stat `json:"stats,omitempty"`
Requirements []projects.RequirementResult `json:"requirements,omitempty"`
Progress []ResultProgress `json:"progress,omitempty"`
}
type ResultProgress struct {
ID int `json:"id"`
Name string `json:"name"`
Weight float64 `json:"weight"`
Acquired int `json:"acquired"`
Required *int `json:"required,omitempty"`
}

249
usecases/sprints/service.go

@ -0,0 +1,249 @@
package sprints
import (
"context"
"git.aiterp.net/stufflog3/stufflog3/entities"
"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"
"math"
"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) 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
}
parts, err := s.Repository.ListParts(ctx, sprints...)
if err != nil {
return nil, err
}
eg, ctx := errgroup.WithContext(ctx)
results := make([]Result, len(sprints))
for i := range sprints {
iCopy := i
eg.Go(func() error {
res, err := s.fill(ctx, 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) fill(ctx context.Context, sprint entities.Sprint, parts []entities.SprintPart) (*Result, error) {
res := Result{Sprint: sprint}
sc := s.Scopes.Context(ctx)
partIDs := make([]int, 0, len(parts))
for _, part := range parts {
if part.SprintID == sprint.ID {
partIDs = append(partIDs, part.PartID)
}
}
allStats, err := sc.Stats(ctx)
if err != nil {
return nil, err
}
progressMap := make(map[int]int, len(allStats))
res.Progress = make([]ResultProgress, 0, len(allStats))
switch res.Kind {
case models.SprintKindItems:
pickedItems, err := s.Items.ListScoped(ctx, models.ItemFilter{
IDs: partIDs,
})
if err != nil {
return nil, err
}
res.Items = pickedItems
for _, stat := range allStats {
totalRequired := 0
for _, item := range res.Items {
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
}
for _, req := range includedRequirements {
res.Items = append(res.Items, req.Items...)
}
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,
})
}
res.Requirements = includedRequirements
case models.SprintKindStats:
includedStats := make([]entities.Stat, 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,
})
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},
})
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,
})
}
}
// Measure progress
for _, item := range res.Items {
if item.AcquiredTime == nil || item.AcquiredTime.Before(sprint.FromTime) || !item.AcquiredTime.Before(sprint.ToTime) {
continue
}
for _, stat := range item.Stats {
if pi, ok := progressMap[stat.ID]; ok {
res.Progress[pi].Acquired += stat.Acquired
}
}
}
// For requirement sprints, remove items array after enumeration
if res.Kind == models.SprintKindRequirements {
res.Items = nil
}
// Calculate aggregate values
aggregateAcquired := 0.0
aggregateTotal := 0.0
for _, progress := range res.Progress {
if res.IsUnweighted {
aggregateAcquired += float64(progress.Acquired)
if progress.Required != nil {
aggregateTotal += float64(*progress.Required)
}
} else {
aggregateAcquired += float64(progress.Acquired) * progress.Weight
if progress.Required != nil {
aggregateTotal += float64(*progress.Required) * progress.Weight
}
}
}
res.AggregateAcquired = int(math.Round(aggregateAcquired))
res.AggregateTotal = int(math.Round(aggregateTotal))
return &res, nil
}
Loading…
Cancel
Save