trak_flow 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +3 -0
- data/CHANGELOG.md +69 -0
- data/COMMITS.md +196 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +281 -0
- data/README.md +479 -0
- data/Rakefile +16 -0
- data/bin/tf +6 -0
- data/bin/tf_mcp +81 -0
- data/docs/.keep +0 -0
- data/docs/api/database.md +434 -0
- data/docs/api/ruby-library.md +349 -0
- data/docs/api/task-model.md +341 -0
- data/docs/assets/stylesheets/extra.css +53 -0
- data/docs/assets/trak_flow.jpg +0 -0
- data/docs/cli/admin-commands.md +369 -0
- data/docs/cli/dependency-commands.md +321 -0
- data/docs/cli/label-commands.md +222 -0
- data/docs/cli/overview.md +163 -0
- data/docs/cli/plan-commands.md +344 -0
- data/docs/cli/task-commands.md +333 -0
- data/docs/core-concepts/dependencies.md +232 -0
- data/docs/core-concepts/labels.md +217 -0
- data/docs/core-concepts/overview.md +178 -0
- data/docs/core-concepts/plans-workflows.md +264 -0
- data/docs/core-concepts/tasks.md +205 -0
- data/docs/getting-started/configuration.md +120 -0
- data/docs/getting-started/installation.md +79 -0
- data/docs/getting-started/quick-start.md +245 -0
- data/docs/index.md +169 -0
- data/docs/mcp/integration.md +302 -0
- data/docs/mcp/overview.md +206 -0
- data/docs/mcp/resources.md +284 -0
- data/docs/mcp/tools.md +457 -0
- data/examples/basic_usage.rb +365 -0
- data/examples/cli_demo.sh +314 -0
- data/examples/mcp/Gemfile +9 -0
- data/examples/mcp/Gemfile.lock +226 -0
- data/examples/mcp/http_demo.rb +232 -0
- data/examples/mcp/stdio_demo.rb +146 -0
- data/lib/trak_flow/cli/admin_commands.rb +136 -0
- data/lib/trak_flow/cli/config_commands.rb +260 -0
- data/lib/trak_flow/cli/dep_commands.rb +71 -0
- data/lib/trak_flow/cli/label_commands.rb +76 -0
- data/lib/trak_flow/cli/main_commands.rb +386 -0
- data/lib/trak_flow/cli/plan_commands.rb +185 -0
- data/lib/trak_flow/cli/workflow_commands.rb +133 -0
- data/lib/trak_flow/cli.rb +110 -0
- data/lib/trak_flow/config/defaults.yml +114 -0
- data/lib/trak_flow/config/section.rb +74 -0
- data/lib/trak_flow/config.rb +276 -0
- data/lib/trak_flow/graph/dependency_graph.rb +288 -0
- data/lib/trak_flow/id_generator.rb +52 -0
- data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
- data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
- data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
- data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
- data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
- data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
- data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
- data/lib/trak_flow/mcp/server.rb +140 -0
- data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
- data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
- data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
- data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
- data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
- data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
- data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
- data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
- data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
- data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
- data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
- data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
- data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
- data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
- data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
- data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
- data/lib/trak_flow/mcp.rb +38 -0
- data/lib/trak_flow/models/comment.rb +71 -0
- data/lib/trak_flow/models/dependency.rb +96 -0
- data/lib/trak_flow/models/label.rb +90 -0
- data/lib/trak_flow/models/task.rb +188 -0
- data/lib/trak_flow/storage/database.rb +638 -0
- data/lib/trak_flow/storage/jsonl.rb +259 -0
- data/lib/trak_flow/time_parser.rb +15 -0
- data/lib/trak_flow/version.rb +5 -0
- data/lib/trak_flow.rb +100 -0
- data/mkdocs.yml +143 -0
- metadata +392 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class TaskUpdate < BaseTool
|
|
7
|
+
tool_name "task_update"
|
|
8
|
+
description "Update task attributes (title, priority, status, description, assignee)"
|
|
9
|
+
|
|
10
|
+
arguments do
|
|
11
|
+
required(:id).filled(:string).description("Task ID to update")
|
|
12
|
+
optional(:title).filled(:string).description("New title")
|
|
13
|
+
optional(:status).filled(:string).description("New status: open, in_progress, blocked, deferred, closed")
|
|
14
|
+
optional(:priority).filled(:integer).description("New priority 0-4")
|
|
15
|
+
optional(:description).filled(:string).description("New description")
|
|
16
|
+
optional(:assignee).filled(:string).description("New assignee")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(id:, title: nil, status: nil, priority: nil, description: nil, assignee: nil)
|
|
20
|
+
self.class.with_database do |db|
|
|
21
|
+
task = db.find_task!(id)
|
|
22
|
+
|
|
23
|
+
task.title = title if title
|
|
24
|
+
task.status = status if status
|
|
25
|
+
task.priority = priority if priority
|
|
26
|
+
task.description = description if description
|
|
27
|
+
task.assignee = assignee if assignee
|
|
28
|
+
|
|
29
|
+
db.update_task(task)
|
|
30
|
+
task.to_h
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class WorkflowDiscard < BaseTool
|
|
7
|
+
tool_name "workflow_discard"
|
|
8
|
+
description "Discard an ephemeral Workflow (deletes it and all its tasks)"
|
|
9
|
+
|
|
10
|
+
arguments do
|
|
11
|
+
required(:id).filled(:string).description("Workflow ID to discard")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(id:)
|
|
15
|
+
self.class.with_database do |db|
|
|
16
|
+
workflow = db.find_task!(id)
|
|
17
|
+
raise TrakFlow::Error, "Can only discard ephemeral Workflows" unless workflow.discardable?
|
|
18
|
+
|
|
19
|
+
db.child_tasks(id).each { |child| db.delete_task(child.id) }
|
|
20
|
+
db.delete_task(id)
|
|
21
|
+
|
|
22
|
+
{ discarded: id, success: true }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Mcp
|
|
5
|
+
module Tools
|
|
6
|
+
class WorkflowSummarize < BaseTool
|
|
7
|
+
tool_name "workflow_summarize"
|
|
8
|
+
description "Summarize and close a Workflow"
|
|
9
|
+
|
|
10
|
+
arguments do
|
|
11
|
+
required(:id).filled(:string).description("Workflow ID to summarize")
|
|
12
|
+
required(:summary).filled(:string).description("Summary text")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(id:, summary:)
|
|
16
|
+
self.class.with_database do |db|
|
|
17
|
+
workflow = db.find_task!(id)
|
|
18
|
+
|
|
19
|
+
workflow.notes = "#{workflow.notes}\n\n[Summary]\n#{summary}".strip
|
|
20
|
+
workflow.close!(reason: "summarized")
|
|
21
|
+
db.update_task(workflow)
|
|
22
|
+
|
|
23
|
+
db.child_tasks(id).each do |child|
|
|
24
|
+
child.close!(reason: "workflow summarized")
|
|
25
|
+
db.update_task(child)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
workflow.to_h
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast_mcp"
|
|
4
|
+
|
|
5
|
+
# Tools
|
|
6
|
+
require_relative "mcp/tools/base_tool"
|
|
7
|
+
require_relative "mcp/tools/task_create"
|
|
8
|
+
require_relative "mcp/tools/task_update"
|
|
9
|
+
require_relative "mcp/tools/task_close"
|
|
10
|
+
require_relative "mcp/tools/task_start"
|
|
11
|
+
require_relative "mcp/tools/task_block"
|
|
12
|
+
require_relative "mcp/tools/task_defer"
|
|
13
|
+
require_relative "mcp/tools/plan_create"
|
|
14
|
+
require_relative "mcp/tools/plan_add_step"
|
|
15
|
+
require_relative "mcp/tools/plan_start"
|
|
16
|
+
require_relative "mcp/tools/plan_run"
|
|
17
|
+
require_relative "mcp/tools/workflow_discard"
|
|
18
|
+
require_relative "mcp/tools/workflow_summarize"
|
|
19
|
+
require_relative "mcp/tools/dep_add"
|
|
20
|
+
require_relative "mcp/tools/dep_remove"
|
|
21
|
+
require_relative "mcp/tools/label_add"
|
|
22
|
+
require_relative "mcp/tools/label_remove"
|
|
23
|
+
require_relative "mcp/tools/comment_add"
|
|
24
|
+
|
|
25
|
+
# Resources
|
|
26
|
+
require_relative "mcp/resources/base_resource"
|
|
27
|
+
require_relative "mcp/resources/task_list"
|
|
28
|
+
require_relative "mcp/resources/task_by_id"
|
|
29
|
+
require_relative "mcp/resources/task_next"
|
|
30
|
+
require_relative "mcp/resources/plan_list"
|
|
31
|
+
require_relative "mcp/resources/plan_by_id"
|
|
32
|
+
require_relative "mcp/resources/workflow_list"
|
|
33
|
+
require_relative "mcp/resources/workflow_by_id"
|
|
34
|
+
require_relative "mcp/resources/label_list"
|
|
35
|
+
require_relative "mcp/resources/dependency_graph"
|
|
36
|
+
|
|
37
|
+
# Server
|
|
38
|
+
require_relative "mcp/server"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a comment on a task
|
|
6
|
+
class Comment
|
|
7
|
+
attr_accessor :id, :task_id, :author, :body, :created_at, :updated_at
|
|
8
|
+
|
|
9
|
+
def initialize(attrs = {})
|
|
10
|
+
@id = attrs[:id] || SecureRandom.uuid
|
|
11
|
+
@task_id = attrs[:task_id]
|
|
12
|
+
@author = attrs[:author] || TrakFlow.config.get("actor")
|
|
13
|
+
@body = attrs[:body]
|
|
14
|
+
@created_at = attrs[:created_at] || Time.now.utc
|
|
15
|
+
@updated_at = attrs[:updated_at] || Time.now.utc
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid?
|
|
19
|
+
errors.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def errors
|
|
23
|
+
errs = []
|
|
24
|
+
errs << "Task ID is required" if task_id.nil? || task_id.to_s.strip.empty?
|
|
25
|
+
errs << "Body is required" if body.nil? || body.strip.empty?
|
|
26
|
+
errs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate!
|
|
30
|
+
raise ValidationError, errors.join(", ") unless valid?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def touch!
|
|
34
|
+
self.updated_at = Time.now.utc
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{
|
|
39
|
+
id: id,
|
|
40
|
+
task_id: task_id,
|
|
41
|
+
author: author,
|
|
42
|
+
body: body,
|
|
43
|
+
created_at: created_at&.iso8601,
|
|
44
|
+
updated_at: updated_at&.iso8601
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_json(*args)
|
|
49
|
+
Oj.dump(to_h, mode: :compat)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def from_hash(hash)
|
|
54
|
+
hash = hash.transform_keys(&:to_sym)
|
|
55
|
+
new(
|
|
56
|
+
id: hash[:id],
|
|
57
|
+
task_id: hash[:task_id],
|
|
58
|
+
author: hash[:author],
|
|
59
|
+
body: hash[:body],
|
|
60
|
+
created_at: TimeParser.parse(hash[:created_at]),
|
|
61
|
+
updated_at: TimeParser.parse(hash[:updated_at])
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def from_json(json_string)
|
|
66
|
+
from_hash(Oj.load(json_string, mode: :compat, symbol_keys: true))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a dependency relationship between two issues
|
|
6
|
+
# Types:
|
|
7
|
+
# - blocks: Hard dependency - target cannot proceed until source is closed
|
|
8
|
+
# - related: Soft link - informational only
|
|
9
|
+
# - parent-child: Hierarchical relationship
|
|
10
|
+
# - discovered-from: Traceability link to origin
|
|
11
|
+
class Dependency
|
|
12
|
+
attr_accessor :id, :source_id, :target_id, :type, :created_at
|
|
13
|
+
|
|
14
|
+
VALID_TYPES = TrakFlow::DEPENDENCY_TYPES
|
|
15
|
+
|
|
16
|
+
# Blocking dependency types affect ready-work calculations
|
|
17
|
+
BLOCKING_TYPES = %w[blocks parent-child].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(attrs = {})
|
|
20
|
+
@id = attrs[:id] || SecureRandom.uuid
|
|
21
|
+
@source_id = attrs[:source_id]
|
|
22
|
+
@target_id = attrs[:target_id]
|
|
23
|
+
@type = attrs[:type] || "blocks"
|
|
24
|
+
@created_at = attrs[:created_at] || Time.now.utc
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def valid?
|
|
28
|
+
errors.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def errors
|
|
32
|
+
errs = []
|
|
33
|
+
errs << "Source ID is required" if source_id.nil? || source_id.strip.empty?
|
|
34
|
+
errs << "Target ID is required" if target_id.nil? || target_id.strip.empty?
|
|
35
|
+
errs << "Invalid type: #{type}" unless VALID_TYPES.include?(type)
|
|
36
|
+
errs << "Self-referential dependency not allowed" if source_id == target_id
|
|
37
|
+
errs
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
raise ValidationError, errors.join(", ") unless valid?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def blocking?
|
|
45
|
+
BLOCKING_TYPES.include?(type)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def blocks?
|
|
49
|
+
type == "blocks"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parent_child?
|
|
53
|
+
type == "parent-child"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def related?
|
|
57
|
+
type == "related"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def discovered_from?
|
|
61
|
+
type == "discovered-from"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_h
|
|
65
|
+
{
|
|
66
|
+
id: id,
|
|
67
|
+
source_id: source_id,
|
|
68
|
+
target_id: target_id,
|
|
69
|
+
type: type,
|
|
70
|
+
created_at: created_at&.iso8601
|
|
71
|
+
}.compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_json(*args)
|
|
75
|
+
Oj.dump(to_h, mode: :compat)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
def from_hash(hash)
|
|
80
|
+
hash = hash.transform_keys(&:to_sym)
|
|
81
|
+
new(
|
|
82
|
+
id: hash[:id],
|
|
83
|
+
source_id: hash[:source_id],
|
|
84
|
+
target_id: hash[:target_id],
|
|
85
|
+
type: hash[:type],
|
|
86
|
+
created_at: TimeParser.parse(hash[:created_at])
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def from_json(json_string)
|
|
91
|
+
from_hash(Oj.load(json_string, mode: :compat, symbol_keys: true))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a label attached to a task
|
|
6
|
+
# Labels provide flexible, multi-dimensional categorization beyond
|
|
7
|
+
# structured fields like status, priority, and type
|
|
8
|
+
class Label
|
|
9
|
+
attr_accessor :id, :task_id, :name, :created_at
|
|
10
|
+
|
|
11
|
+
def initialize(attrs = {})
|
|
12
|
+
@id = attrs[:id] || SecureRandom.uuid
|
|
13
|
+
@task_id = attrs[:task_id]
|
|
14
|
+
@name = attrs[:name]
|
|
15
|
+
@created_at = attrs[:created_at] || Time.now.utc
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def valid?
|
|
19
|
+
errors.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def errors
|
|
23
|
+
errs = []
|
|
24
|
+
errs << "Task ID is required" if task_id.nil? || task_id.to_s.strip.empty?
|
|
25
|
+
errs << "Name is required" if name.nil? || name.strip.empty?
|
|
26
|
+
errs << "Invalid label name" unless valid_name?
|
|
27
|
+
errs
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
raise ValidationError, errors.join(", ") unless valid?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Labels can use dimension:value format for state caching
|
|
35
|
+
def dimension
|
|
36
|
+
return nil unless name&.include?(":")
|
|
37
|
+
|
|
38
|
+
name.split(":").first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def value
|
|
42
|
+
return name unless name&.include?(":")
|
|
43
|
+
|
|
44
|
+
name.split(":", 2).last
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def state_label?
|
|
48
|
+
name&.include?(":")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_h
|
|
52
|
+
{
|
|
53
|
+
id: id,
|
|
54
|
+
task_id: task_id,
|
|
55
|
+
name: name,
|
|
56
|
+
created_at: created_at&.iso8601
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_json(*args)
|
|
61
|
+
Oj.dump(to_h, mode: :compat)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
def from_hash(hash)
|
|
66
|
+
hash = hash.transform_keys(&:to_sym)
|
|
67
|
+
new(
|
|
68
|
+
id: hash[:id],
|
|
69
|
+
task_id: hash[:task_id],
|
|
70
|
+
name: hash[:name],
|
|
71
|
+
created_at: TimeParser.parse(hash[:created_at])
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def from_json(json_string)
|
|
76
|
+
from_hash(Oj.load(json_string, mode: :compat, symbol_keys: true))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def valid_name?
|
|
83
|
+
return false if name.nil?
|
|
84
|
+
|
|
85
|
+
# Allow alphanumeric, hyphens, underscores, and colons (for state labels)
|
|
86
|
+
name.match?(/^[a-zA-Z0-9_:-]+$/)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrakFlow
|
|
4
|
+
module Models
|
|
5
|
+
# Represents a task in the TrakFlow system
|
|
6
|
+
#
|
|
7
|
+
# Tasks serve multiple roles determined by flags:
|
|
8
|
+
# - Plan (blueprint): task.plan? == true
|
|
9
|
+
# - Workflow: task.source_plan_id.present? && !task.plan?
|
|
10
|
+
# - Step (conceptual): child Task of a Plan
|
|
11
|
+
# - Work item: child Task of a Workflow
|
|
12
|
+
#
|
|
13
|
+
# Tasks can be bugs, features, tasks, epics, or chores
|
|
14
|
+
class Task
|
|
15
|
+
attr_accessor :id, :title, :description, :status, :priority, :type,
|
|
16
|
+
:assignee, :parent_id, :created_at, :updated_at, :closed_at,
|
|
17
|
+
:content_hash, :plan, :source_plan_id, :ephemeral, :notes
|
|
18
|
+
|
|
19
|
+
VALID_STATUSES = TrakFlow::STATUSES
|
|
20
|
+
VALID_PRIORITIES = TrakFlow::PRIORITIES
|
|
21
|
+
VALID_TYPES = TrakFlow::TYPES
|
|
22
|
+
|
|
23
|
+
def initialize(attrs = {})
|
|
24
|
+
@id = attrs[:id]
|
|
25
|
+
@title = attrs[:title]
|
|
26
|
+
@description = attrs[:description] || ""
|
|
27
|
+
@status = attrs[:status] || "open"
|
|
28
|
+
@priority = attrs[:priority] || 2
|
|
29
|
+
@type = attrs[:type] || "task"
|
|
30
|
+
@assignee = attrs[:assignee]
|
|
31
|
+
@parent_id = attrs[:parent_id]
|
|
32
|
+
@created_at = attrs[:created_at] || Time.now.utc
|
|
33
|
+
@updated_at = attrs[:updated_at] || Time.now.utc
|
|
34
|
+
@closed_at = attrs[:closed_at]
|
|
35
|
+
@content_hash = attrs[:content_hash]
|
|
36
|
+
@plan = attrs[:plan] || false
|
|
37
|
+
@source_plan_id = attrs[:source_plan_id]
|
|
38
|
+
@ephemeral = attrs[:ephemeral] || false
|
|
39
|
+
@notes = attrs[:notes] || ""
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def valid?
|
|
43
|
+
errors.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def errors
|
|
47
|
+
errs = []
|
|
48
|
+
errs << "Title is required" if title.nil? || title.strip.empty?
|
|
49
|
+
errs << "Invalid status: #{status}" unless VALID_STATUSES.include?(status)
|
|
50
|
+
errs << "Invalid priority: #{priority}" unless VALID_PRIORITIES.include?(priority)
|
|
51
|
+
errs << "Invalid type: #{type}" unless VALID_TYPES.include?(type)
|
|
52
|
+
errs << "Plans cannot be ephemeral" if plan && ephemeral
|
|
53
|
+
errs << "Plans cannot change status" if plan && status != "open"
|
|
54
|
+
errs << "Plans cannot be derived from other Plans" if plan && source_plan_id
|
|
55
|
+
errs
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate!
|
|
59
|
+
raise ValidationError, errors.join(", ") unless valid?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def open?
|
|
63
|
+
status == "open"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def closed?
|
|
67
|
+
status == "closed" || status == "tombstone"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def in_progress?
|
|
71
|
+
status == "in_progress"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def blocked?
|
|
75
|
+
status == "blocked"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def epic?
|
|
79
|
+
type == "epic"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Plan/Workflow role predicates
|
|
83
|
+
|
|
84
|
+
def plan?
|
|
85
|
+
!!plan
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def workflow?
|
|
89
|
+
source_plan_id && !source_plan_id.to_s.empty? && !plan?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ephemeral?
|
|
93
|
+
!!ephemeral
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def executable?
|
|
97
|
+
!plan?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def discardable?
|
|
101
|
+
ephemeral?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def close!(reason: nil)
|
|
105
|
+
self.status = "closed"
|
|
106
|
+
self.closed_at = Time.now.utc
|
|
107
|
+
self.notes = "#{notes}\n[Closed] #{reason}".strip if reason
|
|
108
|
+
touch!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def reopen!(reason: nil)
|
|
112
|
+
self.status = "open"
|
|
113
|
+
self.closed_at = nil
|
|
114
|
+
self.notes = "#{notes}\n[Reopened] #{reason}".strip if reason
|
|
115
|
+
touch!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def touch!
|
|
119
|
+
self.updated_at = Time.now.utc
|
|
120
|
+
update_content_hash!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def append_trace(action, message)
|
|
124
|
+
timestamp = Time.now.utc.iso8601
|
|
125
|
+
entry = "[#{timestamp}] [#{action}] #{message}"
|
|
126
|
+
self.notes = "#{notes}\n#{entry}".strip
|
|
127
|
+
touch!
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def update_content_hash!
|
|
131
|
+
self.content_hash = IdGenerator.content_hash(to_h.except(:content_hash, :updated_at))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_h
|
|
135
|
+
{
|
|
136
|
+
id: id,
|
|
137
|
+
title: title,
|
|
138
|
+
description: description,
|
|
139
|
+
status: status,
|
|
140
|
+
priority: priority,
|
|
141
|
+
type: type,
|
|
142
|
+
assignee: assignee,
|
|
143
|
+
parent_id: parent_id,
|
|
144
|
+
created_at: created_at&.iso8601,
|
|
145
|
+
updated_at: updated_at&.iso8601,
|
|
146
|
+
closed_at: closed_at&.iso8601,
|
|
147
|
+
content_hash: content_hash,
|
|
148
|
+
plan: plan,
|
|
149
|
+
source_plan_id: source_plan_id,
|
|
150
|
+
ephemeral: ephemeral,
|
|
151
|
+
notes: notes
|
|
152
|
+
}.compact
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def to_json(*args)
|
|
156
|
+
Oj.dump(to_h, mode: :compat)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
class << self
|
|
160
|
+
def from_hash(hash)
|
|
161
|
+
hash = hash.transform_keys(&:to_sym)
|
|
162
|
+
new(
|
|
163
|
+
id: hash[:id],
|
|
164
|
+
title: hash[:title],
|
|
165
|
+
description: hash[:description],
|
|
166
|
+
status: hash[:status],
|
|
167
|
+
priority: hash[:priority]&.to_i,
|
|
168
|
+
type: hash[:type],
|
|
169
|
+
assignee: hash[:assignee],
|
|
170
|
+
parent_id: hash[:parent_id],
|
|
171
|
+
created_at: TimeParser.parse(hash[:created_at]),
|
|
172
|
+
updated_at: TimeParser.parse(hash[:updated_at]),
|
|
173
|
+
closed_at: TimeParser.parse(hash[:closed_at]),
|
|
174
|
+
content_hash: hash[:content_hash],
|
|
175
|
+
plan: hash[:plan],
|
|
176
|
+
source_plan_id: hash[:source_plan_id],
|
|
177
|
+
ephemeral: hash[:ephemeral],
|
|
178
|
+
notes: hash[:notes]
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def from_json(json_string)
|
|
183
|
+
from_hash(Oj.load(json_string, mode: :compat, symbol_keys: true))
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|