Browse Source

add issue tasks.

master
Gisle Aune 5 years ago
parent
commit
47d8a027da
  1. 1
      database/database.go
  2. 8
      database/drivers/mysqldriver/db.go
  3. 138
      database/drivers/mysqldriver/issetasks.go
  4. 1
      database/drivers/mysqldriver/issues.go
  5. 14
      database/repositories/issuetaskrepository.go
  6. 6
      graph/gqlgen.yml
  7. 1
      graph/resolvers/activity.resolvers.go
  8. 9
      graph/resolvers/issue.resolvers.go
  9. 92
      graph/resolvers/issuetask.resolvers.go
  10. 115
      graph/resolvers/mutation.resolvers.go
  11. 30
      graph/scalars/duration.go
  12. 8
      graph/schema/issue.gql
  13. 98
      graph/schema/issuetask.gql
  14. 10
      graph/schema/mutation.gql
  15. 1
      graph/schema/project.gql
  16. 9
      graph/schema/scalars.gql
  17. 2
      migrations/mysql/20200503195913_create_table_project_status.sql
  18. 26
      migrations/mysql/20200509143532_create_table_issue_task.sql
  19. 12
      models/issuetask.go
  20. 7
      models/item.go
  21. 19
      models/log.go

1
database/database.go

@ -11,6 +11,7 @@ var ErrDriverNotSupported = errors.New("driver not found or supported")
type Database interface {
Activities() repositories.ActivityRepository
Issues() repositories.IssueRepository
IssueTasks() repositories.IssueTaskRepository
Items() repositories.ItemRepository
Projects() repositories.ProjectRepository
Session() repositories.SessionRepository

8
database/drivers/mysqldriver/db.go

@ -16,6 +16,7 @@ type DB struct {
db *sqlx.DB
activities *activityRepository
issues *issueRepository
issueTasks *issueTaskRepository
items *itemRepository
projects *projectRepository
sessions *sessionRepository
@ -31,6 +32,10 @@ func (db *DB) Issues() repositories.IssueRepository {
return db.issues
}
func (db *DB) IssueTasks() repositories.IssueTaskRepository {
return db.issueTasks
}
func (db *DB) Items() repositories.ItemRepository {
return db.items
}
@ -81,6 +86,7 @@ func Open(connectionString string) (*DB, error) {
return nil, err
}
// Setup repositories
activities := &activityRepository{db: db}
issues := &issueRepository{db: db}
items := &itemRepository{db: db}
@ -88,11 +94,13 @@ func Open(connectionString string) (*DB, error) {
users := &userRepository{db: db}
sessions := &sessionRepository{db: db}
projectStatuses := &projectStatusRepository{db: db}
issueTasks := &issueTaskRepository{db: db}
return &DB{
db: db,
activities: activities,
issues: issues,
issueTasks: issueTasks,
items: items,
projects: projects,
users: users,

138
database/drivers/mysqldriver/issetasks.go

@ -0,0 +1,138 @@
package mysqldriver
import (
"context"
"database/sql"
"errors"
"fmt"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
"time"
)
type issueTaskRepository struct {
db *sqlx.DB
}
func (r *issueTaskRepository) Find(ctx context.Context, id string) (*models.IssueTask, error) {
issueTask := models.IssueTask{}
err := r.db.GetContext(ctx, &issueTask, "SELECT * FROM issue_task WHERE issue_task_id=?", id)
if err != nil {
if err == sql.ErrNoRows {
return nil, xlerrors.NotFound("Issue task")
}
return nil, err
}
return &issueTask, nil
}
func (r *issueTaskRepository) List(ctx context.Context, filter models.IssueTaskFilter) ([]*models.IssueTask, error) {
q := sq.Select("*").From("issue_task")
if filter.ActivityIDs != nil {
q = q.Where(sq.Eq{"activity_id": filter.ActivityIDs})
}
if filter.IssueTaskIDs != nil {
q = q.Where(sq.Eq{"issue_id": filter.IssueTaskIDs})
}
if filter.IssueIDs != nil {
q = q.Where(sq.Eq{"issue_id": filter.IssueIDs})
}
if filter.MinStage != nil {
q = q.Where(sq.GtOrEq{"status_stage": *filter.MinStage})
}
if filter.MaxStage != nil {
q = q.Where(sq.LtOrEq{"status_stage": *filter.MaxStage})
}
query, args, err := q.ToSql()
if err != nil {
return nil, err
}
results := make([]*models.IssueTask, 0, 16)
err = r.db.SelectContext(ctx, &results, query, args...)
if err != nil {
if err == sql.ErrNoRows {
return []*models.IssueTask{}, nil
}
return nil, err
}
return results, nil
}
func (r *issueTaskRepository) Insert(ctx context.Context, task models.IssueTask) (*models.IssueTask, error) {
if task.IssueID == "" {
return nil, errors.New("missing issue id")
}
if task.CreatedTime.IsZero() {
task.CreatedTime = time.Now().Truncate(time.Second)
task.UpdatedTime = task.CreatedTime
}
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return nil, err
}
nextID, err := incCounter(ctx, tx, counterKindIssueSubID, task.IssueID)
if err != nil {
_ = tx.Rollback()
return nil, err
}
task.ID = fmt.Sprintf("%s-%d", task.IssueID, nextID)
_, err = tx.NamedExecContext(ctx, `
INSERT INTO issue_task (
issue_task_id, issue_id, activity_id, created_time,
updated_time, due_time, status_stage, status_name,
name, description, estimated_time, estimated_units,
points_multiplier
) VALUES (
:issue_task_id, :issue_id, :activity_id, :created_time,
:updated_time, :due_time, :status_stage, :status_name,
:name, :description, :estimated_time, :estimated_units,
:points_multiplier
)
`, task)
if err != nil {
_ = tx.Rollback()
return nil, err
}
err = tx.Commit()
if err != nil {
_ = tx.Rollback()
return nil, err
}
return &task, nil
}
func (r *issueTaskRepository) Save(ctx context.Context, task models.IssueTask) error {
_, err := r.db.NamedExecContext(ctx, `
UPDATE issue_task SET
due_time=:due_time,
status_stage=:status_stage,
status_name=:status_name,
name=:name,
description=:description,
estimated_time=:estimated_time,
estimated_units=:estimated_units,
points_multiplier=:points_multiplier
WHERE issue_task_id=:issue_task_id
`, task)
return err
}
func (r *issueTaskRepository) Delete(ctx context.Context, task models.IssueTask) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM issue_task WHERE issue_task_id=? LIMIT 1;", task.ID)
return err
}

1
database/drivers/mysqldriver/issues.go

@ -13,6 +13,7 @@ import (
)
var counterKindIssueID = "NextIssueID"
var counterKindIssueSubID = "NextIssueSubID"
type issueRepository struct {
db *sqlx.DB

14
database/repositories/issuetaskrepository.go

@ -0,0 +1,14 @@
package repositories
import (
"context"
"git.aiterp.net/stufflog/server/models"
)
type IssueTaskRepository interface {
Find(ctx context.Context, id string) (*models.IssueTask, error)
List(ctx context.Context, filter models.IssueTaskFilter) ([]*models.IssueTask, error)
Insert(ctx context.Context, task models.IssueTask) (*models.IssueTask, error)
Save(ctx context.Context, task models.IssueTask) error
Delete(ctx context.Context, task models.IssueTask) error
}

6
graph/gqlgen.yml

@ -14,6 +14,12 @@ models:
fields:
unitName:
resolver: true
IssueTask:
fields:
estimatedUnits:
resolver: true
Duration:
model: git.aiterp.net/stufflog/server/graph/scalars.Duration
resolver:
layout: follow-schema

1
graph/resolvers/activity.resolvers.go

@ -5,6 +5,7 @@ package resolvers
import (
"context"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)

9
graph/resolvers/issue.resolvers.go

@ -76,6 +76,15 @@ func (r *issueResolver) Status(ctx context.Context, obj *models.Issue) (*models.
return status, nil
}
func (r *issueResolver) Tasks(ctx context.Context, obj *models.Issue, filter *models.IssueTaskFilter) ([]*models.IssueTask, error) {
if filter == nil {
filter = &models.IssueTaskFilter{}
}
filter.IssueTaskIDs = []string{obj.ID}
return r.Database.IssueTasks().List(ctx, *filter)
}
// Issue returns graphcore.IssueResolver implementation.
func (r *Resolver) Issue() graphcore.IssueResolver { return &issueResolver{r} }

92
graph/resolvers/issuetask.resolvers.go

@ -0,0 +1,92 @@
package resolvers
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/graph/graphutil"
"git.aiterp.net/stufflog/server/internal/xlerrors"
"git.aiterp.net/stufflog/server/models"
)
func (r *issueTaskResolver) EstimatedUnits(ctx context.Context, obj *models.IssueTask) (*int, error) {
// TODO: Data loader
activity, err := r.Database.Activities().Find(ctx, obj.ActivityID)
if err != nil {
return nil, err
}
if !activity.Countable || activity.UnitIsTimeSpent {
return nil, nil
}
return &obj.EstimatedUnits, nil
}
func (r *issueTaskResolver) Issue(ctx context.Context, obj *models.IssueTask) (*models.Issue, error) {
return r.Database.Issues().Find(ctx, obj.IssueID)
}
func (r *issueTaskResolver) Activity(ctx context.Context, obj *models.IssueTask) (*models.Activity, error) {
return r.Database.Activities().Find(ctx, obj.ActivityID)
}
func (r *issueTaskResolver) Status(ctx context.Context, obj *models.IssueTask) (*models.ProjectStatus, error) {
// The project ID is always the prefix before the first dash in the issue ID.
split := strings.SplitN(obj.IssueID, "-", 2)
if len(split) == 0 {
return nil, errors.New("invalid issue ID")
}
projectID := split[0]
// Shortcut: if description isn't needed, resolve this with issue's properties.
if !graphutil.SelectsAnyField(ctx, "description") {
return &models.ProjectStatus{
ProjectID: projectID,
Stage: obj.StatusStage,
Name: obj.StatusName,
Description: "FAKE",
}, nil
}
// Find it in the database. TODO: DataLoader
status, err := r.Database.ProjectStatuses().Find(ctx, projectID, obj.StatusName)
if xlerrors.IsNotFound(err) {
return &models.ProjectStatus{
ProjectID: projectID,
Stage: obj.StatusStage,
Name: obj.StatusName,
Description: "(Deleted or unknown status)",
}, nil
} else if err != nil {
return nil, err
}
// If the stage doesn't match, sneakily correct it for next time.
if status.Stage != obj.StatusStage {
updatedTask := *obj
updatedTask.StatusStage = status.Stage
_ = r.Database.IssueTasks().Save(ctx, updatedTask)
}
return status, nil
}
func (r *issueTaskResolver) RemainingTime(ctx context.Context, obj *models.IssueTask) (time.Duration, error) {
panic(fmt.Errorf("not implemented"))
}
func (r *issueTaskResolver) RemainingUnits(ctx context.Context, obj *models.IssueTask) (*int, error) {
panic(fmt.Errorf("not implemented"))
}
// IssueTask returns graphcore.IssueTaskResolver implementation.
func (r *Resolver) IssueTask() graphcore.IssueTaskResolver { return &issueTaskResolver{r} }
type issueTaskResolver struct{ *Resolver }

115
graph/resolvers/mutation.resolvers.go

@ -6,6 +6,7 @@ package resolvers
import (
"context"
"errors"
"time"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/internal/xlerrors"
@ -169,6 +170,120 @@ func (r *mutationResolver) CreateIssue(ctx context.Context, input graphcore.Issu
return issue, nil
}
func (r *mutationResolver) CreateIssueTask(ctx context.Context, input graphcore.IssueTaskCreateInput) (*models.IssueTask, error) {
user := r.Auth.UserFromContext(ctx)
if user == nil {
return nil, xlerrors.PermissionDenied
}
issue, err := r.Database.Issues().Find(ctx, input.IssueID)
if err != nil {
return nil, err
}
if perm, err := r.Auth.IssuePermission(ctx, *issue); err != nil || !perm.CanManageOwnIssue() {
return nil, xlerrors.PermissionDenied
}
status, err := r.Database.ProjectStatuses().Find(ctx, issue.ProjectID, input.StatusName)
if err != nil {
return nil, err
}
activity, err := r.Database.Activities().Find(ctx, input.ActivityID)
if err != nil {
return nil, err
} else if activity.ProjectID != issue.ProjectID {
return nil, xlerrors.NotFound("Activity")
}
issueTask := &models.IssueTask{
IssueID: issue.ID,
ActivityID: activity.ID,
CreatedTime: time.Now(),
UpdatedTime: time.Now(),
StatusStage: status.Stage,
StatusName: status.Name,
Name: input.Name,
Description: input.Description,
PointsMultiplier: 1.0,
}
if input.EstimatedUnits != nil && activity.Countable && !activity.UnitIsTimeSpent {
issueTask.EstimatedUnits = *input.EstimatedUnits
}
if input.PointsMultiplier != nil && *input.PointsMultiplier > 0 {
issueTask.PointsMultiplier = *input.PointsMultiplier
}
issueTask, err = r.Database.IssueTasks().Insert(ctx, *issueTask)
if err != nil {
return nil, err
}
return issueTask, nil
}
func (r *mutationResolver) EditIssueTask(ctx context.Context, input graphcore.IssueTaskEditInput) (*models.IssueTask, error) {
user := r.Auth.UserFromContext(ctx)
if user == nil {
return nil, xlerrors.PermissionDenied
}
task, err := r.Database.IssueTasks().Find(ctx, input.IssueTaskID)
if err != nil {
return nil, err
}
issue, err := r.Database.Issues().Find(ctx, task.IssueID)
if err != nil {
return nil, err
}
if perm, err := r.Auth.IssuePermission(ctx, *issue); err != nil || !perm.CanManageOwnIssue() {
return nil, xlerrors.PermissionDenied
}
if input.SetName != nil {
task.Name = *input.SetName
}
if input.SetDescription != nil {
task.Description = *input.SetDescription
}
if input.SetDueTime != nil {
task.DueTime = input.SetDueTime
}
if input.SetEstimatedTime != nil {
task.EstimatedTime = *input.SetEstimatedTime
}
if input.SetEstimatedUnits != nil {
activity, err := r.Database.Activities().Find(ctx, task.ActivityID)
if err != nil {
return nil, err
}
if activity.Countable && !activity.UnitIsTimeSpent {
task.EstimatedUnits = *input.SetEstimatedUnits
}
}
if input.SetPointsMultiplier != nil && *input.SetPointsMultiplier > 0 {
task.PointsMultiplier = *input.SetPointsMultiplier
}
if input.SetStatusName != nil {
status, err := r.Database.ProjectStatuses().Find(ctx, issue.ProjectID, *input.SetStatusName)
if err != nil {
return nil, err
}
task.StatusName = status.Name
task.StatusStage = status.Stage
}
err = r.Database.IssueTasks().Save(ctx, *task)
if err != nil {
return nil, err
}
return task, nil
}
func (r *mutationResolver) LoginUser(ctx context.Context, input graphcore.UserLoginInput) (*models.User, error) {
return r.Auth.Login(ctx, input.Username, input.Password)
}

30
graph/scalars/duration.go

@ -0,0 +1,30 @@
package scalars
import (
"fmt"
"github.com/99designs/gqlgen/graphql"
"io"
"strconv"
"time"
)
func MarshalDuration(d time.Duration) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
_, _ = w.Write([]byte(strconv.Itoa(int(d.Milliseconds()))))
})
}
func UnmarshalDuration(v interface{}) (time.Duration, error) {
switch v := v.(type) {
case string:
return time.ParseDuration(v)
case int:
return time.Millisecond * time.Duration(v), nil
case int64:
return time.Millisecond * time.Duration(v), nil
case float64:
return time.Millisecond * time.Duration(v), nil
default:
return 0, fmt.Errorf("%T is not a bool", v)
}
}

8
graph/schema/issue.gql

@ -18,10 +18,14 @@ type Issue {
project: Project
"The issue's owner/creator."
owner: User
"The issue assignee."
"The issue's assignee."
assignee: User
"The issue status."
"Current status of the issue."
status: ProjectStatus!
"Issue tasks."
tasks(filter: IssueTaskFilter): [IssueTask!]!
#"Logs related to this issue."
#logs: [Log!]!
}
input IssueFilter {

98
graph/schema/issuetask.gql

@ -0,0 +1,98 @@
"""
An issue task is a the main part of an issue. They contain estimates, and which activity
is to be performed. They don't get their own page in UI and should have all their information
presented in a concise manner.
"""
type IssueTask {
"The issue task ID."
id: String!
"The time when the task was created."
createdTime: Time!
"The time when the task was updated."
updatedTime: Time!
"The time when the task is due."
dueTime: Time
"A name for the task."
name: String!
"A short description of the task."
description: String!
"The estimated time."
estimatedTime: Duration!
"The estimated units."
estimatedUnits: Int
"A multiplier for the points earned."
pointsMultiplier: Float!
"Parent issue."
issue: Issue!
"Activity the task performs."
activity: Activity!
"The status of this task."
status: ProjectStatus!
#"Logs related to this task."
#logs: [Log!]!
"Remaining time from the logs."
remainingTime: Duration!
"Remaining units (if countable)"
remainingUnits: Int
}
"""
A subset of the filter for Issue.tasks
"""
input IssueTaskFilter {
"The activity IDs to limit the task list with."
activityIds: [String!]
"The lowest stage (inclusive)."
minStage: Int
"The highest stage (inclusive)."
maxStage: Int
}
"""
Input for the createIssueTask mutation.
"""
input IssueTaskCreateInput {
"The issue ID to parent to."
issueId: String!
"The activity ID this task is about."
activityId: String!
"The name of the task."
name: String!
"The description of the task."
description: String!
"Estimated time to perform the task."
estimatedTime: Duration!
"Task status."
statusName: String!
"Estimate an amount of units. This is required for issues with a countable activity."
estimatedUnits: Int
"Set an optional multiplier for the issue."
pointsMultiplier: Float
}
"""
Input for the editIssueTask mutation.
"""
input IssueTaskEditInput {
"The issue task to edit."
issueTaskId: String!
"Update the status."
setStatusName: String
"Set the name."
setName: String
"Set description."
setDescription: String
"Set estimated time."
setEstimatedTime: Duration
"Set estimated units."
setEstimatedUnits: Int
"Set points multiplier."
setPointsMultiplier: Float
"Set due time."
setDueTime: Time
}

10
graph/schema/mutation.gql

@ -9,9 +9,19 @@ type Mutation {
"Edit an activity."
editActivity(input: ActivityEditInput!): Activity!
# ITEM
# ISSUE
"Create a new issue."
createIssue(input: IssueCreateInput!): Issue!
# ISSUE TASK
"Create a new issue task."
createIssueTask(input: IssueTaskCreateInput!): IssueTask!
"Edit an issue task."
editIssueTask(input: IssueTaskEditInput!): IssueTask!
# ISSUE ITEM
# LOG
# USER
"Log in."

1
graph/schema/project.gql

@ -29,6 +29,7 @@ type ProjectPermission {
user: User
}
"The project's statuses for issues and issue tasks."
type ProjectStatus {
"The stage of the status. 0=inactive, 1=pending, 2=active, 3=review, 4=completed, 5=failed, 6=postponed"
stage: Int!

9
graph/schema/scalars.gql

@ -1 +1,10 @@
"""
A ISO3339 timestamp.
"""
scalar Time
"""
Duration in milliseconds. It will output as an integer, but can be supplied as a float, integer
or a string accepted by the Go time.Duration parser.
"""
scalar Duration

2
migrations/mysql/20200503195913_create_table_project_status.sql

@ -2,7 +2,7 @@
-- +goose StatementBegin
CREATE TABLE project_status (
project_id CHAR(16) NOT NULL,
name VARCHAR(255) NOT NULL,
name CHAR(32) NOT NULL,
stage INT NOT NULL,
description VARCHAR(255) NOT NULL,

26
migrations/mysql/20200509143532_create_table_issue_task.sql

@ -0,0 +1,26 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE issue_task (
issue_task_id CHAR(48) PRIMARY KEY,
issue_id CHAR(32) NOT NULL,
activity_id CHAR(16) NOT NULL,
created_time TIMESTAMP NOT NULL,
updated_time TIMESTAMP NOT NULL,
due_time TIMESTAMP,
status_stage INT NOT NULL,
status_name CHAR(32) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
estimated_time BIGINT NOT NULL,
estimated_units INT NOT NULL,
points_multiplier FLOAT NOT NULL,
INDEX (issue_id),
INDEX (activity_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE issue_task;
-- +goose StatementEnd

12
models/issuetask.go

@ -4,12 +4,12 @@ import "time"
// An IssueTask is a task within an issue.
type IssueTask struct {
TaskID string `db:"task_id"`
ID string `db:"issue_task_id"`
IssueID string `db:"issue_id"`
ActivityID string `db:"activity_id"`
CreatedTime time.Time `db:"created_time"`
UpdatedTime time.Time `db:"updated_time"`
DueTime time.Time `db:"due_time"`
DueTime *time.Time `db:"due_time"`
StatusStage int `db:"status_stage"`
StatusName string `db:"status_name"`
Name string `db:"name"`
@ -18,3 +18,11 @@ type IssueTask struct {
EstimatedUnits int `db:"estimated_units"`
PointsMultiplier float64 `db:"points_multiplier"`
}
type IssueTaskFilter struct {
IssueTaskIDs []string
IssueIDs []string
ActivityIDs []string
MinStage *int
MaxStage *int
}

7
models/item.go

@ -13,10 +13,3 @@ type ItemFilter struct {
ItemIDs []string
Tags []string
}
/*
SELECT i.item_id, i.name FROM item i
LEFT JOIN tag AS t ON t.item_id = i.item_id
WHERE t.tag_name IN ("Groceries")
GROUP by i.item_id;
*/

19
models/log.go

@ -1,6 +1,25 @@
package models
import "time"
type Log struct {
ID string `db:"log_id"`
IssueID string `db:"issue_id"`
UserID string `db:"user_id"`
IssueTaskID *string `db:"issue_task_id"`
UnitsDone *int `db:"units_done"`
Duration time.Duration `db:"duration"`
Description []string `db:"description"`
}
type LogFilter struct {
LogIDs []string
IssueIDs []string
IssueTaskIDs []string
}
type LogItem struct {
LogID string `db:"log_id"`
IssueItemID string `db:"issue_item_id"`
Amount int `db:"amount"`
}
Loading…
Cancel
Save