aidp 0.44.0 → 0.46.0

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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Aidp
6
+ module Watch
7
+ # Synchronizes parsed Gantt task data with GitHub Projects and Mermaid docs.
8
+ class GanttSynchronizer
9
+ CRITICAL_PATH_VALUES = %w[Yes No].freeze
10
+
11
+ def initialize(repository_client:, state_store:, project_id:, field_mappings:, auto_create_fields: true, parser_class: PrdParser)
12
+ @repository_client = repository_client
13
+ @state_store = state_store
14
+ @project_id = project_id
15
+ @field_mappings = field_mappings
16
+ @auto_create_fields = auto_create_fields
17
+ @parser_class = parser_class
18
+ @project_fields_cache = nil
19
+ end
20
+
21
+ def sync_from_prd(prd_path:, format: nil, issue_numbers_by_title: {})
22
+ parsed = @parser_class.new(file_path: prd_path, format: format).parse
23
+ tasks = resolve_issue_numbers(parsed[:tasks], issue_numbers_by_title)
24
+ critical_path = calculate_critical_path(tasks)
25
+ tasks_by_id = tasks.each_with_object({}) { |task, memo| memo[task[:id]] = task }
26
+
27
+ synced = 0
28
+ skipped = 0
29
+
30
+ tasks.each do |task|
31
+ if task[:issue_number].nil?
32
+ skipped += 1
33
+ next
34
+ end
35
+
36
+ item_id = ensure_project_item(task[:issue_number])
37
+ update_date_fields(item_id, task)
38
+ update_dependencies_field(item_id, task, tasks_by_id)
39
+ update_critical_path_field(item_id, critical_path.include?(task[:id]))
40
+
41
+ @state_store.record_project_sync(task[:issue_number], {
42
+ gantt_task_id: task[:id],
43
+ gantt_path: prd_path,
44
+ gantt_format: parsed[:format].to_s,
45
+ critical_path: critical_path.include?(task[:id])
46
+ })
47
+ synced += 1
48
+ end
49
+
50
+ {synced: synced, skipped: skipped, critical_path: critical_path}
51
+ end
52
+
53
+ def sync_issue_status_to_gantt(prd_path:, issue_number:, status:, format: nil)
54
+ parser = @parser_class.new(file_path: prd_path, format: format)
55
+ parsed = parser.parse
56
+ return false unless parsed[:format] == :mermaid
57
+
58
+ content = File.read(prd_path)
59
+ lines = content.lines
60
+ line_index = mermaid_task_line_index(content, parsed[:tasks], issue_number)
61
+ return false unless line_index
62
+
63
+ lines[line_index] = rewrite_mermaid_status(lines[line_index], status)
64
+ File.write(prd_path, lines.join)
65
+ true
66
+ end
67
+
68
+ private
69
+
70
+ def resolve_issue_numbers(tasks, issue_numbers_by_title)
71
+ tasks.map do |task|
72
+ next task if task[:issue_number]
73
+
74
+ resolved_issue_number = issue_numbers_by_title[normalize_title(task[:name])]
75
+ resolved_issue_number ? task.merge(issue_number: resolved_issue_number) : task
76
+ end
77
+ end
78
+
79
+ def calculate_critical_path(tasks)
80
+ explicit = tasks.select { |task| task[:critical] }.map { |task| task[:id] }
81
+ return explicit if explicit.any?
82
+
83
+ durations = tasks.each_with_object({}) { |task, memo| memo[task[:id]] = task[:duration_days].to_i }
84
+ predecessors = tasks.each_with_object({}) { |task, memo| memo[task[:id]] = task[:dependency_ids] }
85
+ longest = {}
86
+ previous = {}
87
+
88
+ ordered_ids(tasks, predecessors).each do |task_id|
89
+ best_predecessor = predecessors[task_id].max_by { |candidate| longest[candidate].to_i }
90
+ longest[task_id] = durations[task_id] + longest[best_predecessor].to_i
91
+ previous[task_id] = best_predecessor
92
+ end
93
+
94
+ endpoint = longest.max_by { |_task_id, distance| distance }&.first
95
+ unwind_path(endpoint, previous)
96
+ end
97
+
98
+ def ordered_ids(tasks, predecessors)
99
+ task_ids = tasks.map { |task| task[:id] }
100
+ ordered = []
101
+ remaining = task_ids.dup
102
+
103
+ until remaining.empty?
104
+ progressed = false
105
+ remaining.dup.each do |task_id|
106
+ unmet = predecessors[task_id].reject { |dependency| ordered.include?(dependency) }
107
+ next if unmet.any?
108
+
109
+ ordered << task_id
110
+ remaining.delete(task_id)
111
+ progressed = true
112
+ end
113
+
114
+ break unless progressed
115
+ end
116
+
117
+ ordered + remaining
118
+ end
119
+
120
+ def unwind_path(endpoint, previous)
121
+ path = []
122
+ current = endpoint
123
+
124
+ while current
125
+ path.unshift(current)
126
+ current = previous[current]
127
+ end
128
+
129
+ path
130
+ end
131
+
132
+ def ensure_project_item(issue_number)
133
+ item_id = @state_store.project_item_id(issue_number)
134
+ return item_id if item_id
135
+
136
+ item_id = @repository_client.link_issue_to_project(@project_id, issue_number)
137
+ @state_store.record_project_item_id(issue_number, item_id)
138
+ item_id
139
+ end
140
+
141
+ def update_date_fields(item_id, task)
142
+ update_date_field(item_id, @field_mappings[:start_date], task[:start_date])
143
+ update_date_field(item_id, @field_mappings[:target_date], task[:end_date] || task[:start_date])
144
+ end
145
+
146
+ def update_date_field(item_id, field_name, date)
147
+ return unless field_name && date
148
+
149
+ field = find_or_create_field(field_name, "DATE")
150
+ return unless field
151
+
152
+ @repository_client.update_project_item_field(
153
+ item_id,
154
+ field[:id],
155
+ {project_id: @project_id, date: date.iso8601}
156
+ )
157
+ end
158
+
159
+ def update_dependencies_field(item_id, task, tasks_by_id)
160
+ field_name = @field_mappings[:dependencies]
161
+ return unless field_name
162
+
163
+ dependency_issue_numbers = task[:dependency_ids].filter_map do |dependency_id|
164
+ tasks_by_id[dependency_id]&.dig(:issue_number)
165
+ end
166
+ field = find_or_create_field(field_name, "TEXT")
167
+ return unless field
168
+
169
+ text = dependency_issue_numbers.map { |issue_number| "##{issue_number}" }.join(", ")
170
+ @repository_client.update_project_item_field(
171
+ item_id,
172
+ field[:id],
173
+ {project_id: @project_id, text: text}
174
+ )
175
+ end
176
+
177
+ def update_critical_path_field(item_id, critical)
178
+ field_name = @field_mappings[:critical_path]
179
+ return unless field_name
180
+
181
+ field = find_or_create_field(field_name, "SINGLE_SELECT", CRITICAL_PATH_VALUES)
182
+ return unless field
183
+
184
+ option_id = field[:options]&.find { |option| option[:name].casecmp?(critical ? "Yes" : "No") }&.dig(:id)
185
+ return unless option_id
186
+
187
+ @repository_client.update_project_item_field(
188
+ item_id,
189
+ field[:id],
190
+ {project_id: @project_id, option_id: option_id}
191
+ )
192
+ end
193
+
194
+ def rewrite_mermaid_status(line, status)
195
+ match = line.match(/\A(?<name_part>.*?)(?<separator>\s*:\s*)(?<definition>.*?)(?<newline>\r?\n?)\z/)
196
+ return line unless match
197
+
198
+ mapped_status = mermaid_status_for(status)
199
+
200
+ tokens = match[:definition].split(",").map(&:strip).reject(&:empty?)
201
+ tokens.reject! { |token| %w[done active].include?(token) }
202
+
203
+ tokens.unshift(mapped_status) if mapped_status
204
+
205
+ "#{match[:name_part]}#{match[:separator]}#{tokens.join(", ")}#{match[:newline]}"
206
+ end
207
+
208
+ def mermaid_status_for(status)
209
+ case status.to_s.downcase
210
+ when "done", "closed", "completed" then "done"
211
+ when "in progress", "in_progress", "active" then "active"
212
+ end
213
+ end
214
+
215
+ def mermaid_task_line_index(content, tasks, issue_number)
216
+ line_number = find_mermaid_task(tasks, issue_number)&.dig(:line_number)
217
+ return unless line_number
218
+
219
+ line_number + mermaid_chart_line_offset(content) - 1
220
+ end
221
+
222
+ def mermaid_chart_line_offset(content)
223
+ lines = content.lines
224
+ mermaid_start = lines.each_index.find do |index|
225
+ mermaid_gantt_block?(lines, index)
226
+ end
227
+ mermaid_start ? mermaid_start + 1 : 0
228
+ end
229
+
230
+ def find_mermaid_task(tasks, issue_number)
231
+ sync_data = @state_store.project_sync_data(issue_number)
232
+ gantt_task_id = sync_data["gantt_task_id"]
233
+
234
+ tasks.find { |task| task[:id] == gantt_task_id } ||
235
+ tasks.find { |task| task[:issue_number] == issue_number }
236
+ end
237
+
238
+ def mermaid_gantt_block?(lines, start_index)
239
+ return false unless lines[start_index].match?(/\A```mermaid\b/)
240
+
241
+ block_lines = lines[(start_index + 1)..]
242
+ return false unless block_lines
243
+
244
+ block_lines.take_while { |line| !line.match?(/\A\s*```\s*$/) }.any? do |line|
245
+ line.strip == "gantt"
246
+ end
247
+ end
248
+
249
+ def normalize_title(title)
250
+ title.to_s.gsub(/\s*\(#\d+\)\s*/, " ").strip.downcase
251
+ end
252
+
253
+ def project_fields
254
+ @project_fields_cache ||= @repository_client.fetch_project_fields(@project_id)
255
+ end
256
+
257
+ def invalidate_fields_cache
258
+ @project_fields_cache = nil
259
+ end
260
+
261
+ def find_or_create_field(name, field_type, options = nil)
262
+ field = project_fields.find { |entry| entry[:name].casecmp?(name) }
263
+ return field if field
264
+ return nil unless @auto_create_fields
265
+
266
+ formatted_options = options&.map { |option| {name: option} } if field_type == "SINGLE_SELECT"
267
+ field = @repository_client.create_project_field(@project_id, name, field_type, options: formatted_options)
268
+ invalidate_fields_cache
269
+ field
270
+ end
271
+ end
272
+ end
273
+ end
@@ -263,6 +263,10 @@ module Aidp
263
263
  return record_project_setup_failure(issue[:number], "unable to create project sub-issues")
264
264
  end
265
265
 
266
+ if gantt_sync_enabled? && !sync_project_gantt_data(projects_processor, issue[:number], created_issues)
267
+ return record_project_setup_failure(issue[:number], "unable to sync GitHub Project data from the PRD Gantt chart")
268
+ end
269
+
266
270
  unless sync_project_issue_statuses(issue[:number], created_issues, projects_processor)
267
271
  return record_project_setup_failure(issue[:number], "unable to sync project issues to the GitHub Project")
268
272
  end
@@ -320,6 +324,24 @@ module Aidp
320
324
  true
321
325
  end
322
326
 
327
+ def sync_project_gantt_data(projects_processor, issue_number, created_issues)
328
+ projects_processor.sync_from_gantt(issue_numbers_by_title: issue_numbers_by_title(created_issues))
329
+ true
330
+ rescue => e
331
+ Aidp.log_error("plan_processor", "project_gantt_sync_failed",
332
+ issue: issue_number, error: e.message)
333
+ false
334
+ end
335
+
336
+ def prd_path_configured?
337
+ value = @project_config[:prd_path] || @project_config["prd_path"]
338
+ !value.to_s.empty?
339
+ end
340
+
341
+ def gantt_sync_enabled?
342
+ (@project_config[:auto_sync_gantt] || @project_config["auto_sync_gantt"]) == true && prd_path_configured?
343
+ end
344
+
323
345
  def existing_sub_issues_for(parent_issue, sub_issues:, creator:)
324
346
  parent_number = parent_issue[:number]
325
347
  existing_numbers = @state_store.sub_issues(parent_number)
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "date"
5
+ require "rexml/document"
6
+
7
+ module Aidp
8
+ module Watch
9
+ # Parses Gantt-oriented planning documents into a normalized task graph.
10
+ class PrdParser
11
+ class ParseError < StandardError; end
12
+
13
+ SUPPORTED_FORMATS = %i[mermaid ms_project_xml csv].freeze
14
+
15
+ def initialize(file_path:, format: nil)
16
+ @file_path = file_path
17
+ @format = normalize_format(format) || detect_format
18
+ end
19
+
20
+ def parse
21
+ raise ParseError, "File not found: #{@file_path}" unless File.exist?(@file_path)
22
+
23
+ tasks = case @format
24
+ when :mermaid then parse_mermaid(File.read(@file_path))
25
+ when :ms_project_xml then parse_ms_project_xml(File.read(@file_path))
26
+ when :csv then parse_csv
27
+ else
28
+ raise ParseError, "Unsupported format: #{@format}"
29
+ end
30
+
31
+ normalized_tasks = normalize_tasks(tasks)
32
+
33
+ {
34
+ format: @format,
35
+ source_file: @file_path,
36
+ tasks: normalized_tasks,
37
+ metadata: build_metadata(normalized_tasks)
38
+ }
39
+ rescue REXML::ParseException => e
40
+ raise ParseError, "Invalid XML: #{e.message}"
41
+ rescue CSV::MalformedCSVError => e
42
+ raise ParseError, "Invalid CSV: #{e.message}"
43
+ end
44
+
45
+ private
46
+
47
+ def normalize_format(format)
48
+ return nil if format.nil?
49
+
50
+ normalized = format.to_s.strip.downcase.tr("-", "_")
51
+ {
52
+ "auto" => nil,
53
+ "mermaid" => :mermaid,
54
+ "ms_project_xml" => :ms_project_xml,
55
+ "msp_xml" => :ms_project_xml,
56
+ "xml" => :ms_project_xml,
57
+ "csv" => :csv
58
+ }.fetch(normalized) { raise ParseError, "Unknown format override: #{format}" }
59
+ end
60
+
61
+ def detect_format
62
+ ext = File.extname(@file_path).downcase
63
+ content = File.read(@file_path)
64
+
65
+ return :csv if ext == ".csv"
66
+ return :ms_project_xml if ext == ".xml"
67
+ return :mermaid if mermaid_gantt_format?(ext, content)
68
+
69
+ raise ParseError, "Unable to detect Gantt format for #{@file_path}"
70
+ end
71
+
72
+ def mermaid_gantt_format?(ext, content)
73
+ return false unless [".md", ".markdown", ".mmd"].include?(ext) || content.match?(/^\s*gantt\s*$/)
74
+
75
+ content.match?(/```mermaid\b.*?^\s*gantt\s*$/m) || content.match?(/^\s*gantt\s*$/)
76
+ end
77
+
78
+ def parse_mermaid(content)
79
+ chart = extract_mermaid_chart(content)
80
+ lines = chart.each_line.map(&:rstrip)
81
+ chart_start_date = extract_mermaid_start_date(lines)
82
+ section = nil
83
+ tasks = []
84
+
85
+ lines.each_with_index do |line, index|
86
+ stripped = line.strip
87
+ next if stripped.empty? || stripped == "gantt" || stripped.start_with?("%%", "title ", "dateFormat ", "axisFormat ", "todayMarker ")
88
+
89
+ if stripped.start_with?("section ")
90
+ section = stripped.delete_prefix("section ").strip
91
+ next
92
+ end
93
+
94
+ next unless stripped.include?(":")
95
+
96
+ name_part, definition = stripped.split(":", 2)
97
+ tasks << build_mermaid_task(name_part, definition, section, index + 1, chart_start_date)
98
+ end
99
+
100
+ tasks
101
+ end
102
+
103
+ def extract_mermaid_chart(content)
104
+ mermaid_blocks(content).each do |block|
105
+ return block if block.each_line.any? { |line| line.strip == "gantt" }
106
+ end
107
+
108
+ content
109
+ end
110
+
111
+ def mermaid_blocks(content)
112
+ content.scan(/```mermaid\s*\n(?<chart>.*?^\s*```)/m).flatten.map do |chart|
113
+ chart.sub(/\n?\s*```\s*\z/m, "")
114
+ end
115
+ end
116
+
117
+ def build_mermaid_task(name_part, definition, section, line_number, chart_start_date)
118
+ tokens = definition.split(",").map(&:strip).reject(&:empty?)
119
+ dependency_ids = []
120
+ milestone = false
121
+ critical = false
122
+ status = nil
123
+ task_id = nil
124
+ explicit_dates = []
125
+ duration_days = nil
126
+
127
+ tokens.each do |token|
128
+ case token
129
+ when "crit"
130
+ critical = true
131
+ when "milestone", "milestone?"
132
+ milestone = true
133
+ when "done", "active"
134
+ status = token
135
+ when /\Aafter\s+(.+)\z/i
136
+ dependency_ids.concat(Regexp.last_match(1).split)
137
+ when /\A\d{4}-\d{2}-\d{2}\z/
138
+ explicit_dates << Date.parse(token)
139
+ when /\A(\d+)d\z/i
140
+ duration_days = Regexp.last_match(1).to_i
141
+ else
142
+ task_id ||= token
143
+ end
144
+ end
145
+
146
+ start_date = explicit_dates.first
147
+ end_date = explicit_dates[1]
148
+ duration_days ||= 0 if milestone
149
+ duration_days ||= 1 unless end_date
150
+
151
+ {
152
+ id: task_id || slugify(name_part),
153
+ name: name_part.strip,
154
+ section: section,
155
+ start_date: start_date,
156
+ end_date: end_date || ((start_date && duration_days) ? start_date + duration_days - 1 : nil),
157
+ duration_days: duration_days,
158
+ dependency_ids: dependency_ids,
159
+ milestone: milestone,
160
+ critical: critical,
161
+ status: status,
162
+ issue_number: extract_issue_number("#{name_part} #{task_id}"),
163
+ line_number: line_number,
164
+ chart_start_date: chart_start_date
165
+ }
166
+ end
167
+
168
+ def parse_ms_project_xml(content)
169
+ document = REXML::Document.new(content)
170
+ uid_to_task = {}
171
+
172
+ REXML::XPath.each(document, "//Task") do |node|
173
+ name = text_at(node, "Name")
174
+ next if name.to_s.strip.empty?
175
+ next if text_at(node, "Null") == "1"
176
+
177
+ uid = text_at(node, "UID")
178
+ task = {
179
+ id: uid.to_s.empty? ? slugify(name) : uid.to_s,
180
+ name: name.strip,
181
+ section: nil,
182
+ start_date: parse_date(text_at(node, "Start")),
183
+ end_date: parse_date(text_at(node, "Finish")),
184
+ duration_days: parse_duration(text_at(node, "Duration")),
185
+ dependency_ids: [],
186
+ milestone: text_at(node, "Milestone") == "1",
187
+ critical: text_at(node, "Critical") == "1",
188
+ status: nil,
189
+ issue_number: extract_issue_number(name)
190
+ }
191
+
192
+ REXML::XPath.each(node, "PredecessorLink") do |link|
193
+ predecessor_uid = text_at(link, "PredecessorUID")
194
+ task[:dependency_ids] << predecessor_uid if predecessor_uid
195
+ end
196
+
197
+ uid_to_task[uid] = task
198
+ end
199
+
200
+ uid_to_task.values
201
+ end
202
+
203
+ def parse_csv
204
+ rows = CSV.read(@file_path, headers: true)
205
+ rows.map do |row|
206
+ name = first_value(row, "name", "task", "title", "summary")
207
+ id = first_value(row, "id", "task_id", "uid") || slugify(name)
208
+ dependencies = first_value(row, "dependencies", "depends_on", "predecessors", "dependency_ids")
209
+
210
+ {
211
+ id: id.to_s,
212
+ name: name.to_s.strip,
213
+ section: first_value(row, "section", "phase", "group"),
214
+ start_date: parse_date(first_value(row, "start_date", "start")),
215
+ end_date: parse_date(first_value(row, "end_date", "finish", "due_date")),
216
+ duration_days: parse_numeric(first_value(row, "duration_days", "duration", "days")),
217
+ dependency_ids: split_dependencies(dependencies),
218
+ milestone: truthy?(first_value(row, "milestone", "is_milestone")),
219
+ critical: truthy?(first_value(row, "critical", "critical_path")),
220
+ status: first_value(row, "status", "state"),
221
+ issue_number: parse_numeric(first_value(row, "issue_number")) || extract_issue_number("#{name} #{id}")
222
+ }
223
+ end.reject { |task| task[:name].empty? }
224
+ end
225
+
226
+ def normalize_tasks(tasks)
227
+ task_ids = tasks.each_with_object({}) { |task, memo| memo[task[:id].to_s] = task[:id].to_s }
228
+
229
+ normalized_tasks = tasks.map do |task|
230
+ dependency_ids = Array(task[:dependency_ids]).filter_map do |dependency|
231
+ resolve_dependency_id(dependency, tasks, task_ids)
232
+ end
233
+
234
+ duration_days = task[:duration_days]
235
+ duration_days = calculate_duration(task[:start_date], task[:end_date]) if duration_days.nil?
236
+ duration_days ||= task[:milestone] ? 0 : 1
237
+
238
+ task.merge(
239
+ id: task[:id].to_s,
240
+ dependency_ids: dependency_ids.uniq,
241
+ duration_days: duration_days,
242
+ end_date: task[:end_date] || infer_end_date(task[:start_date], duration_days)
243
+ )
244
+ end
245
+
246
+ infer_dependency_dates(infer_chart_order_dates(normalized_tasks))
247
+ end
248
+
249
+ def infer_chart_order_dates(tasks)
250
+ root_tasks = tasks.select { |task| task[:dependency_ids].empty? }
251
+ return tasks if root_tasks.empty?
252
+
253
+ cursor = tasks.filter_map { |task| task[:chart_start_date] }.min || Date.today
254
+ root_tasks_by_id = root_tasks.each_with_object({}) do |task, memo|
255
+ start_date = task[:start_date] || cursor
256
+ end_date = task[:end_date] || infer_end_date(start_date, task[:duration_days])
257
+ memo[task[:id]] = task.merge(start_date: start_date, end_date: end_date, chart_start_date: nil)
258
+ next unless end_date
259
+
260
+ next_cursor = end_date + 1
261
+ cursor = [cursor, next_cursor].max
262
+ end
263
+
264
+ tasks.map do |task|
265
+ root_tasks_by_id.fetch(task[:id]) { task.merge(chart_start_date: nil) }
266
+ end
267
+ end
268
+
269
+ def resolve_dependency_id(dependency, tasks, task_ids)
270
+ key = dependency.to_s.strip
271
+ return if key.empty?
272
+ return task_ids[key] if task_ids[key]
273
+
274
+ task = tasks.find { |candidate| candidate[:name].casecmp?(key) }
275
+ task&.dig(:id)&.to_s
276
+ end
277
+
278
+ def build_metadata(tasks)
279
+ {
280
+ task_count: tasks.size,
281
+ milestone_count: tasks.count { |task| task[:milestone] },
282
+ dependency_count: tasks.sum { |task| task[:dependency_ids].size },
283
+ issues_mapped: tasks.count { |task| task[:issue_number] }
284
+ }
285
+ end
286
+
287
+ def calculate_duration(start_date, end_date)
288
+ return unless start_date && end_date
289
+
290
+ [(end_date - start_date).to_i + 1, 0].max
291
+ end
292
+
293
+ def infer_end_date(start_date, duration_days)
294
+ return unless start_date && duration_days
295
+ return start_date if duration_days.zero?
296
+
297
+ start_date + duration_days - 1
298
+ end
299
+
300
+ def infer_dependency_dates(tasks)
301
+ tasks_by_id = tasks.each_with_object({}) { |task, memo| memo[task[:id]] = task }
302
+
303
+ tasks.map do |task|
304
+ resolved_task = resolve_dependency_dates(task, tasks_by_id, {})
305
+ tasks_by_id[task[:id]] = resolved_task
306
+ end
307
+ end
308
+
309
+ def resolve_dependency_dates(task, tasks_by_id, visiting)
310
+ return task if task[:start_date] && task[:end_date]
311
+ return task if task[:dependency_ids].empty?
312
+ return task if visiting[task[:id]]
313
+
314
+ visiting[task[:id]] = true
315
+ dependency_end_dates = task[:dependency_ids].filter_map do |dependency_id|
316
+ dependency = tasks_by_id[dependency_id]
317
+ next unless dependency
318
+
319
+ resolved_dependency = resolve_dependency_dates(dependency, tasks_by_id, visiting)
320
+ tasks_by_id[dependency_id] = resolved_dependency
321
+ resolved_dependency[:end_date]
322
+ end
323
+ visiting.delete(task[:id])
324
+
325
+ return task if dependency_end_dates.empty?
326
+
327
+ start_date = task[:start_date] || dependency_end_dates.max + 1
328
+ task.merge(
329
+ start_date: start_date,
330
+ end_date: task[:end_date] || infer_end_date(start_date, task[:duration_days])
331
+ )
332
+ end
333
+
334
+ def text_at(node, path)
335
+ child = node.elements[path]
336
+ child&.text
337
+ end
338
+
339
+ def parse_date(value)
340
+ return if value.to_s.strip.empty?
341
+
342
+ Date.parse(value.to_s)
343
+ end
344
+
345
+ def extract_mermaid_start_date(lines)
346
+ value = lines.filter_map do |line|
347
+ line.strip.match(/\A%%\s*start_date:\s*(\d{4}-\d{2}-\d{2})\s*\z/i)&.captures&.first
348
+ end.first
349
+ parse_date(value)
350
+ end
351
+
352
+ def parse_duration(value)
353
+ return if value.to_s.strip.empty?
354
+
355
+ return value.to_i if value.to_s.match?(/\A\d+\z/)
356
+
357
+ hours = value.to_s.scan(/(\d+)H/i).flatten.first.to_i
358
+ minutes = value.to_s.scan(/(\d+)M/i).flatten.first.to_i
359
+ days = (hours / 8.0) + (minutes.positive? ? 1.0 / 8 : 0)
360
+ days.ceil
361
+ end
362
+
363
+ def parse_numeric(value)
364
+ return if value.to_s.strip.empty?
365
+
366
+ value.to_s[/\d+/]&.to_i
367
+ end
368
+
369
+ def first_value(row, *keys)
370
+ keys.each do |key|
371
+ value = row[key] || row[key.to_s] || row[key.to_sym]
372
+ return value unless value.nil?
373
+ end
374
+
375
+ nil
376
+ end
377
+
378
+ def split_dependencies(value)
379
+ value.to_s.split(/[,;|]/).map(&:strip).reject(&:empty?)
380
+ end
381
+
382
+ def truthy?(value)
383
+ %w[true yes y 1].include?(value.to_s.strip.downcase)
384
+ end
385
+
386
+ def extract_issue_number(value)
387
+ value.to_s.match(/#(\d+)|\bissue[-\s]?(\d+)\b|\(#(\d+)\)/i)&.captures&.compact&.first&.to_i
388
+ end
389
+
390
+ def slugify(value)
391
+ value.to_s.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
392
+ end
393
+ end
394
+ end
395
+ end