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.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +3 -0
  3. data/CHANGELOG.md +69 -0
  4. data/COMMITS.md +196 -0
  5. data/Gemfile +8 -0
  6. data/Gemfile.lock +281 -0
  7. data/README.md +479 -0
  8. data/Rakefile +16 -0
  9. data/bin/tf +6 -0
  10. data/bin/tf_mcp +81 -0
  11. data/docs/.keep +0 -0
  12. data/docs/api/database.md +434 -0
  13. data/docs/api/ruby-library.md +349 -0
  14. data/docs/api/task-model.md +341 -0
  15. data/docs/assets/stylesheets/extra.css +53 -0
  16. data/docs/assets/trak_flow.jpg +0 -0
  17. data/docs/cli/admin-commands.md +369 -0
  18. data/docs/cli/dependency-commands.md +321 -0
  19. data/docs/cli/label-commands.md +222 -0
  20. data/docs/cli/overview.md +163 -0
  21. data/docs/cli/plan-commands.md +344 -0
  22. data/docs/cli/task-commands.md +333 -0
  23. data/docs/core-concepts/dependencies.md +232 -0
  24. data/docs/core-concepts/labels.md +217 -0
  25. data/docs/core-concepts/overview.md +178 -0
  26. data/docs/core-concepts/plans-workflows.md +264 -0
  27. data/docs/core-concepts/tasks.md +205 -0
  28. data/docs/getting-started/configuration.md +120 -0
  29. data/docs/getting-started/installation.md +79 -0
  30. data/docs/getting-started/quick-start.md +245 -0
  31. data/docs/index.md +169 -0
  32. data/docs/mcp/integration.md +302 -0
  33. data/docs/mcp/overview.md +206 -0
  34. data/docs/mcp/resources.md +284 -0
  35. data/docs/mcp/tools.md +457 -0
  36. data/examples/basic_usage.rb +365 -0
  37. data/examples/cli_demo.sh +314 -0
  38. data/examples/mcp/Gemfile +9 -0
  39. data/examples/mcp/Gemfile.lock +226 -0
  40. data/examples/mcp/http_demo.rb +232 -0
  41. data/examples/mcp/stdio_demo.rb +146 -0
  42. data/lib/trak_flow/cli/admin_commands.rb +136 -0
  43. data/lib/trak_flow/cli/config_commands.rb +260 -0
  44. data/lib/trak_flow/cli/dep_commands.rb +71 -0
  45. data/lib/trak_flow/cli/label_commands.rb +76 -0
  46. data/lib/trak_flow/cli/main_commands.rb +386 -0
  47. data/lib/trak_flow/cli/plan_commands.rb +185 -0
  48. data/lib/trak_flow/cli/workflow_commands.rb +133 -0
  49. data/lib/trak_flow/cli.rb +110 -0
  50. data/lib/trak_flow/config/defaults.yml +114 -0
  51. data/lib/trak_flow/config/section.rb +74 -0
  52. data/lib/trak_flow/config.rb +276 -0
  53. data/lib/trak_flow/graph/dependency_graph.rb +288 -0
  54. data/lib/trak_flow/id_generator.rb +52 -0
  55. data/lib/trak_flow/mcp/resources/base_resource.rb +25 -0
  56. data/lib/trak_flow/mcp/resources/dependency_graph.rb +31 -0
  57. data/lib/trak_flow/mcp/resources/label_list.rb +21 -0
  58. data/lib/trak_flow/mcp/resources/plan_by_id.rb +27 -0
  59. data/lib/trak_flow/mcp/resources/plan_list.rb +21 -0
  60. data/lib/trak_flow/mcp/resources/task_by_id.rb +31 -0
  61. data/lib/trak_flow/mcp/resources/task_list.rb +21 -0
  62. data/lib/trak_flow/mcp/resources/task_next.rb +30 -0
  63. data/lib/trak_flow/mcp/resources/workflow_by_id.rb +27 -0
  64. data/lib/trak_flow/mcp/resources/workflow_list.rb +21 -0
  65. data/lib/trak_flow/mcp/server.rb +140 -0
  66. data/lib/trak_flow/mcp/tools/base_tool.rb +29 -0
  67. data/lib/trak_flow/mcp/tools/comment_add.rb +33 -0
  68. data/lib/trak_flow/mcp/tools/dep_add.rb +34 -0
  69. data/lib/trak_flow/mcp/tools/dep_remove.rb +25 -0
  70. data/lib/trak_flow/mcp/tools/label_add.rb +28 -0
  71. data/lib/trak_flow/mcp/tools/label_remove.rb +25 -0
  72. data/lib/trak_flow/mcp/tools/plan_add_step.rb +35 -0
  73. data/lib/trak_flow/mcp/tools/plan_create.rb +33 -0
  74. data/lib/trak_flow/mcp/tools/plan_run.rb +58 -0
  75. data/lib/trak_flow/mcp/tools/plan_start.rb +58 -0
  76. data/lib/trak_flow/mcp/tools/task_block.rb +27 -0
  77. data/lib/trak_flow/mcp/tools/task_close.rb +26 -0
  78. data/lib/trak_flow/mcp/tools/task_create.rb +51 -0
  79. data/lib/trak_flow/mcp/tools/task_defer.rb +27 -0
  80. data/lib/trak_flow/mcp/tools/task_start.rb +25 -0
  81. data/lib/trak_flow/mcp/tools/task_update.rb +36 -0
  82. data/lib/trak_flow/mcp/tools/workflow_discard.rb +28 -0
  83. data/lib/trak_flow/mcp/tools/workflow_summarize.rb +34 -0
  84. data/lib/trak_flow/mcp.rb +38 -0
  85. data/lib/trak_flow/models/comment.rb +71 -0
  86. data/lib/trak_flow/models/dependency.rb +96 -0
  87. data/lib/trak_flow/models/label.rb +90 -0
  88. data/lib/trak_flow/models/task.rb +188 -0
  89. data/lib/trak_flow/storage/database.rb +638 -0
  90. data/lib/trak_flow/storage/jsonl.rb +259 -0
  91. data/lib/trak_flow/time_parser.rb +15 -0
  92. data/lib/trak_flow/version.rb +5 -0
  93. data/lib/trak_flow.rb +100 -0
  94. data/mkdocs.yml +143 -0
  95. 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