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.
@@ -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] || DEFAULT_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