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
-
11graph/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
-
23models/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 @@ |
|||
scalar Time |
|||
""" |
|||
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 |
@ -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 |
|||
|
|||
import "time" |
|||
|
|||
type Log struct { |
|||
ID string `db:"log_id"` |
|||
IssueID string `db:"issue_id"` |
|||
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"` |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue