add issue tasks.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.aiterp.net/stufflog/server/graph/graphcore"
|
||||
"git.aiterp.net/stufflog/server/models"
|
||||
)
|
||||
|
||||
@@ -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} }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user