aidp 0.43.0 → 0.45.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/config.rb +15 -1
- data/lib/aidp/setup/wizard.rb +14 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/gantt_synchronizer.rb +273 -0
- data/lib/aidp/watch/plan_generator.rb +2 -1
- data/lib/aidp/watch/plan_processor.rb +494 -18
- 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 +212 -1
- data/lib/aidp/watch/runner.rb +117 -9
- data/lib/aidp/watch/state_store.rb +41 -1
- data/lib/aidp/watch/sub_issue_creator.rb +133 -19
- metadata +3 -1
|
@@ -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
|
|
@@ -15,7 +15,11 @@ module Aidp
|
|
|
15
15
|
priority: "Priority",
|
|
16
16
|
skills: "Skills",
|
|
17
17
|
personas: "Personas",
|
|
18
|
-
blocking: "Blocking"
|
|
18
|
+
blocking: "Blocking",
|
|
19
|
+
start_date: "Start Date",
|
|
20
|
+
target_date: "Target Date",
|
|
21
|
+
dependencies: "Dependencies",
|
|
22
|
+
critical_path: "Critical Path"
|
|
19
23
|
}.freeze
|
|
20
24
|
|
|
21
25
|
# Status values for different issue states
|
|
@@ -27,17 +31,25 @@ module Aidp
|
|
|
27
31
|
done: "Done",
|
|
28
32
|
blocked: "Blocked"
|
|
29
33
|
}.freeze
|
|
34
|
+
CRITICAL_PATH_VALUES = %w[Yes No].freeze
|
|
30
35
|
|
|
31
36
|
attr_reader :repository_client, :state_store, :project_id
|
|
32
37
|
|
|
33
|
-
def initialize(repository_client:, state_store:, project_id:, config: {})
|
|
38
|
+
def initialize(repository_client:, state_store:, project_id:, config: {}, gantt_synchronizer: nil)
|
|
34
39
|
@repository_client = repository_client
|
|
35
40
|
@state_store = state_store
|
|
36
41
|
@project_id = project_id
|
|
37
|
-
@config = config
|
|
38
|
-
@field_mappings = config[:field_mappings] ||
|
|
39
|
-
@auto_create_fields = config[:auto_create_fields] != false
|
|
42
|
+
@config = normalize_config(config)
|
|
43
|
+
@field_mappings = DEFAULT_FIELD_MAPPINGS.merge(@config[:field_mappings] || {})
|
|
44
|
+
@auto_create_fields = @config[:auto_create_fields] != false
|
|
40
45
|
@project_fields_cache = nil
|
|
46
|
+
@gantt_synchronizer = gantt_synchronizer || GanttSynchronizer.new(
|
|
47
|
+
repository_client: repository_client,
|
|
48
|
+
state_store: state_store,
|
|
49
|
+
project_id: project_id,
|
|
50
|
+
field_mappings: @field_mappings,
|
|
51
|
+
auto_create_fields: @auto_create_fields
|
|
52
|
+
)
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
# Sync a single issue to the project
|
|
@@ -66,7 +78,7 @@ module Aidp
|
|
|
66
78
|
|
|
67
79
|
# Update status if provided
|
|
68
80
|
if status
|
|
69
|
-
update_issue_status(issue_number, status)
|
|
81
|
+
return false unless update_issue_status(issue_number, status)
|
|
70
82
|
end
|
|
71
83
|
|
|
72
84
|
# Check and update blocking status
|
|
@@ -76,6 +88,7 @@ module Aidp
|
|
|
76
88
|
last_sync: Time.now.utc.iso8601,
|
|
77
89
|
status: status
|
|
78
90
|
})
|
|
91
|
+
sync_issue_status_to_gantt(issue_number, status) if status && gantt_sync_enabled?
|
|
79
92
|
|
|
80
93
|
true
|
|
81
94
|
rescue => e
|
|
@@ -178,6 +191,16 @@ module Aidp
|
|
|
178
191
|
{synced: synced, failed: failed}
|
|
179
192
|
end
|
|
180
193
|
|
|
194
|
+
def sync_from_gantt(prd_path = configured_prd_path, issue_numbers_by_title: {})
|
|
195
|
+
return {synced: 0, skipped: 0, critical_path: []} unless prd_path
|
|
196
|
+
|
|
197
|
+
@gantt_synchronizer.sync_from_prd(
|
|
198
|
+
prd_path: prd_path,
|
|
199
|
+
format: configured_gantt_format,
|
|
200
|
+
issue_numbers_by_title: issue_numbers_by_title
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
181
204
|
# Initialize required project fields if they don't exist
|
|
182
205
|
# @return [Boolean] True if all fields are ready
|
|
183
206
|
def ensure_project_fields
|
|
@@ -185,11 +208,6 @@ module Aidp
|
|
|
185
208
|
|
|
186
209
|
Aidp.log_debug("projects_processor", "ensure_project_fields", project_id: @project_id)
|
|
187
210
|
|
|
188
|
-
required_fields = [
|
|
189
|
-
{name: @field_mappings[:status], type: "SINGLE_SELECT", options: STATUS_VALUES.values},
|
|
190
|
-
{name: @field_mappings[:blocking], type: "TEXT"}
|
|
191
|
-
]
|
|
192
|
-
|
|
193
211
|
all_ready = true
|
|
194
212
|
required_fields.each do |field_spec|
|
|
195
213
|
field = find_or_create_field(field_spec[:name], field_spec[:type], field_spec[:options])
|
|
@@ -210,6 +228,19 @@ module Aidp
|
|
|
210
228
|
end
|
|
211
229
|
end
|
|
212
230
|
|
|
231
|
+
def normalize_config(value)
|
|
232
|
+
case value
|
|
233
|
+
when Hash
|
|
234
|
+
value.each_with_object({}) do |(key, nested_value), normalized|
|
|
235
|
+
normalized[key.to_sym] = normalize_config(nested_value)
|
|
236
|
+
end
|
|
237
|
+
when Array
|
|
238
|
+
value.map { |item| normalize_config(item) }
|
|
239
|
+
else
|
|
240
|
+
value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
213
244
|
def invalidate_fields_cache
|
|
214
245
|
@project_fields_cache = nil
|
|
215
246
|
end
|
|
@@ -281,6 +312,52 @@ module Aidp
|
|
|
281
312
|
def clear_blocking_field(issue_number)
|
|
282
313
|
update_blocking_field(issue_number, [])
|
|
283
314
|
end
|
|
315
|
+
|
|
316
|
+
def gantt_sync_enabled?
|
|
317
|
+
@config[:auto_sync_gantt] == true && !configured_prd_path.to_s.empty?
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def required_fields
|
|
321
|
+
[
|
|
322
|
+
{name: @field_mappings[:status], type: "SINGLE_SELECT", options: STATUS_VALUES.values},
|
|
323
|
+
{name: @field_mappings[:blocking], type: "TEXT"}
|
|
324
|
+
] + gantt_required_fields
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def gantt_required_fields
|
|
328
|
+
return [] unless gantt_sync_enabled?
|
|
329
|
+
|
|
330
|
+
[
|
|
331
|
+
{name: @field_mappings[:start_date], type: "DATE"},
|
|
332
|
+
{name: @field_mappings[:target_date], type: "DATE"},
|
|
333
|
+
{name: @field_mappings[:dependencies], type: "TEXT"},
|
|
334
|
+
{name: @field_mappings[:critical_path], type: "SINGLE_SELECT", options: CRITICAL_PATH_VALUES}
|
|
335
|
+
]
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def configured_prd_path
|
|
339
|
+
@config[:prd_path]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def configured_gantt_format
|
|
343
|
+
format = @config[:gantt_format]
|
|
344
|
+
return nil if format.nil? || format.to_s == "auto"
|
|
345
|
+
|
|
346
|
+
format
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def sync_issue_status_to_gantt(issue_number, status)
|
|
350
|
+
@gantt_synchronizer.sync_issue_status_to_gantt(
|
|
351
|
+
prd_path: configured_prd_path,
|
|
352
|
+
issue_number: issue_number,
|
|
353
|
+
status: status,
|
|
354
|
+
format: configured_gantt_format
|
|
355
|
+
)
|
|
356
|
+
rescue => e
|
|
357
|
+
Aidp.log_warn("projects_processor", "gantt_status_sync_failed",
|
|
358
|
+
issue_number: issue_number, error: e.message)
|
|
359
|
+
false
|
|
360
|
+
end
|
|
284
361
|
end
|
|
285
362
|
end
|
|
286
363
|
end
|