aidp 0.44.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d898c7dea670e05503b8777e0907e0d0f975d398fd3cf80d9ddc659e8806070c
4
- data.tar.gz: c25d8e50f56ce0e89f93c3e5fea9d4fb0ca6d540c19ac5fd3d2710d38be57290
3
+ metadata.gz: a86b02df6fbe151abffa67c335c73fc3c724de523065f1f03b083bca305c1c6c
4
+ data.tar.gz: 15cf32cc8d3007f91e7a94a777e1b32d1a10002a63f91f10721bcf7ffe5c3d04
5
5
  SHA512:
6
- metadata.gz: ec307da67468acc15043f72c0295f443a7650a4312d1767f12629fbf582d02354a76cf2476081a313cb434c48eb63f5ec06d6d7d90f39d00d972342725d0dbe5
7
- data.tar.gz: 7bbb9cbfff2543d753b56ca940c19745b0b4678d9e4825c418639c5ec2cbeeac99069de846e75e8c8ef05a8d83b825d13d7e99b056a462e07a89c1f56a2cb96a
6
+ metadata.gz: dc49aca1c586e8603be81e229ad3ce42663a5d9592995cc593ed7929c48339542cb67d8b9c3056b429d8409b016c2ddcc4619938f9905666d23a3f0ced1cf006
7
+ data.tar.gz: 1dac0b372011909722cc0d139bee899b4b968d3041fba11114f7314a9ce5b5a7b8037799b3484e1c03f8587312aae507bad44469c29bbb9b102c6035ad45dea0
data/lib/aidp/config.rb CHANGED
@@ -215,10 +215,17 @@ module Aidp
215
215
  priority: "Priority",
216
216
  skills: "Skills",
217
217
  personas: "Personas",
218
- blocking: "Blocking"
218
+ blocking: "Blocking",
219
+ start_date: "Start Date",
220
+ target_date: "Target Date",
221
+ dependencies: "Dependencies",
222
+ critical_path: "Critical Path"
219
223
  },
220
224
  auto_create_fields: true,
221
225
  sync_interval: 60,
226
+ prd_path: nil,
227
+ auto_sync_gantt: false,
228
+ gantt_format: "auto",
222
229
  default_status_values: ["Backlog", "Todo", "In Progress", "In Review", "Done"],
223
230
  default_priority_values: ["Low", "Medium", "High", "Critical"]
224
231
  },
@@ -512,6 +519,11 @@ module Aidp
512
519
  merged[:security] = deep_merge_hash(merged[:security], symbolize_keys(security_section))
513
520
  end
514
521
 
522
+ if config[:watch] || config["watch"]
523
+ watch_section = config[:watch] || config["watch"]
524
+ merged[:watch] = deep_merge_hash(merged[:watch], symbolize_keys(watch_section))
525
+ end
526
+
515
527
  merged
516
528
  end
517
529
 
data/lib/aidp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aidp
4
- VERSION = "0.44.0"
4
+ VERSION = "0.45.0"
5
5
  end
@@ -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
@@ -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
@@ -1644,6 +1644,21 @@ module Aidp
1644
1644
  }
1645
1645
  }
1646
1646
  GRAPHQL
1647
+ elsif value.is_a?(Hash) && value[:date]
1648
+ <<~GRAPHQL
1649
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
1650
+ updateProjectV2ItemFieldValue(input: {
1651
+ projectId: $projectId
1652
+ itemId: $itemId
1653
+ fieldId: $fieldId
1654
+ value: {date: $date}
1655
+ }) {
1656
+ projectV2Item {
1657
+ id
1658
+ }
1659
+ }
1660
+ }
1661
+ GRAPHQL
1647
1662
  else
1648
1663
  # Text field
1649
1664
  <<~GRAPHQL
@@ -1675,8 +1690,10 @@ module Aidp
1675
1690
 
1676
1691
  if value.is_a?(Hash) && value[:option_id]
1677
1692
  variables[:optionId] = value[:option_id]
1693
+ elsif value.is_a?(Hash) && value[:date]
1694
+ variables[:date] = value[:date]
1678
1695
  else
1679
- variables[:text] = value.to_s
1696
+ variables[:text] = value.is_a?(Hash) ? value[:text].to_s : value.to_s
1680
1697
  end
1681
1698
 
1682
1699
  result = execute_graphql_query(mutation, **variables)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.44.0
4
+ version: 0.45.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -609,11 +609,13 @@ files:
609
609
  - lib/aidp/watch/ci_fix_processor.rb
610
610
  - lib/aidp/watch/ci_log_extractor.rb
611
611
  - lib/aidp/watch/feedback_collector.rb
612
+ - lib/aidp/watch/gantt_synchronizer.rb
612
613
  - lib/aidp/watch/github_state_extractor.rb
613
614
  - lib/aidp/watch/hierarchical_pr_strategy.rb
614
615
  - lib/aidp/watch/implementation_verifier.rb
615
616
  - lib/aidp/watch/plan_generator.rb
616
617
  - lib/aidp/watch/plan_processor.rb
618
+ - lib/aidp/watch/prd_parser.rb
617
619
  - lib/aidp/watch/projects_processor.rb
618
620
  - lib/aidp/watch/rebase_processor.rb
619
621
  - lib/aidp/watch/repository_client.rb