Gisle Aune
5 years ago
21 changed files with 598 additions and 15 deletions
-
1database/database.go
-
8database/drivers/mysqldriver/db.go
-
138database/drivers/mysqldriver/issetasks.go
-
1database/drivers/mysqldriver/issues.go
-
14database/repositories/issuetaskrepository.go
-
6graph/gqlgen.yml
-
1graph/resolvers/activity.resolvers.go
-
9graph/resolvers/issue.resolvers.go
-
92graph/resolvers/issuetask.resolvers.go
-
115graph/resolvers/mutation.resolvers.go
-
30graph/scalars/duration.go
-
8graph/schema/issue.gql
-
98graph/schema/issuetask.gql
-
10graph/schema/mutation.gql
-
1graph/schema/project.gql
-
9graph/schema/scalars.gql
-
2migrations/mysql/20200503195913_create_table_project_status.sql
-
26migrations/mysql/20200509143532_create_table_issue_task.sql
-
12models/issuetask.go
-
7models/item.go
-
19models/log.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 |
||||
|
} |
@ -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 |
||||
|
} |
@ -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 } |
@ -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) |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
@ -1 +1,10 @@ |
|||||
|
""" |
||||
|
A ISO3339 timestamp. |
||||
|
""" |
||||
scalar Time |
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 |
@ -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 |
@ -1,6 +1,25 @@ |
|||||
package models |
package models |
||||
|
|
||||
|
import "time" |
||||
|
|
||||
type Log struct { |
type Log struct { |
||||
ID string `db:"log_id"` |
ID string `db:"log_id"` |
||||
IssueID string `db:"issue_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"` |
||||
} |
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue