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.
- checksums.yaml +4 -4
- data/lib/aidp/cli/devcontainer_commands.rb +10 -4
- data/lib/aidp/config.rb +13 -1
- data/lib/aidp/setup/devcontainer/jsonc_document.rb +539 -0
- data/lib/aidp/setup/devcontainer/parser.rb +6 -3
- data/lib/aidp/setup/wizard.rb +7 -1
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/gantt_synchronizer.rb +273 -0
- data/lib/aidp/watch/plan_processor.rb +22 -0
- data/lib/aidp/watch/prd_parser.rb +395 -0
- data/lib/aidp/watch/projects_processor.rb +88 -11
- data/lib/aidp/watch/repository_client.rb +18 -1
- metadata +4 -1
|
@@ -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
|