add issue tasks.

This commit is contained in:
2020-05-10 20:50:18 +02:00
parent 3039dc1879
commit 47d8a027da
21 changed files with 598 additions and 15 deletions
+6
View File
@@ -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
View File
@@ -5,6 +5,7 @@ package resolvers
import (
"context"
"git.aiterp.net/stufflog/server/graph/graphcore"
"git.aiterp.net/stufflog/server/models"
)
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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!
+10 -1
View File
@@ -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