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.
@@ -4,6 +4,8 @@ require_relative "../message_display"
4
4
  require_relative "plan_generator"
5
5
  require_relative "state_store"
6
6
  require_relative "feedback_collector"
7
+ require_relative "projects_processor"
8
+ require_relative "sub_issue_creator"
7
9
 
8
10
  module Aidp
9
11
  module Watch
@@ -12,39 +14,48 @@ module Aidp
12
14
  class PlanProcessor
13
15
  include Aidp::MessageDisplay
14
16
 
17
+ TrackedSubIssuesRetirementError = Class.new(StandardError)
18
+
15
19
  # Default label names
16
20
  DEFAULT_PLAN_LABEL = "aidp-plan"
21
+ DEFAULT_PROJECT_LABEL = "aidp-project"
17
22
  DEFAULT_NEEDS_INPUT_LABEL = "aidp-needs-input"
18
23
  DEFAULT_READY_LABEL = "aidp-ready"
19
24
  DEFAULT_BUILD_LABEL = "aidp-build"
25
+ DEFAULT_BLOCKED_LABEL = "aidp-blocked"
20
26
 
21
27
  COMMENT_HEADER = "## ๐Ÿค– AIDP Plan Proposal"
22
28
 
23
- attr_reader :plan_label, :needs_input_label, :ready_label, :build_label
29
+ attr_reader :plan_label, :project_label, :needs_input_label, :ready_label, :build_label, :blocked_label
24
30
 
25
- def initialize(repository_client:, state_store:, plan_generator:, label_config: {})
31
+ def initialize(repository_client:, state_store:, plan_generator:, label_config: {}, project_config: {})
26
32
  @repository_client = repository_client
27
33
  @state_store = state_store
28
34
  @plan_generator = plan_generator
35
+ @project_config = project_config
29
36
 
30
37
  # Load label configuration with defaults
31
38
  @plan_label = label_config[:plan_trigger] || label_config["plan_trigger"] || DEFAULT_PLAN_LABEL
39
+ @project_label = label_config[:project_trigger] || label_config["project_trigger"] || DEFAULT_PROJECT_LABEL
32
40
  @needs_input_label = label_config[:needs_input] || label_config["needs_input"] || DEFAULT_NEEDS_INPUT_LABEL
33
41
  @ready_label = label_config[:ready_to_build] || label_config["ready_to_build"] || DEFAULT_READY_LABEL
34
42
  @build_label = label_config[:build_trigger] || label_config["build_trigger"] || DEFAULT_BUILD_LABEL
43
+ @blocked_label = label_config[:blocked_trigger] || label_config["blocked_trigger"] || DEFAULT_BLOCKED_LABEL
35
44
  end
36
45
 
37
- def process(issue)
46
+ def process(issue, trigger_label: @plan_label)
38
47
  number = issue[:number]
39
48
  existing_plan = @state_store.plan_data(number)
49
+ project_mode = trigger_label.to_s.casecmp(@project_label).zero?
40
50
 
41
51
  if existing_plan
42
52
  display_message("๐Ÿ”„ Re-planning for issue ##{number} (iteration #{@state_store.plan_iteration_count(number) + 1})", type: :info)
43
53
  else
44
- display_message("๐Ÿง  Generating plan for issue ##{number} (#{issue[:title]})", type: :info)
54
+ prefix = project_mode ? "๐Ÿ—‚๏ธ Generating project plan" : "๐Ÿง  Generating plan"
55
+ display_message("#{prefix} for issue ##{number} (#{issue[:title]})", type: :info)
45
56
  end
46
57
 
47
- plan_data = @plan_generator.generate(issue)
58
+ plan_data = @plan_generator.generate(issue, hierarchical: project_mode)
48
59
 
49
60
  # If plan generation failed (all providers unavailable), silently skip
50
61
  unless plan_data
@@ -59,7 +70,13 @@ module Aidp
59
70
  # If updating existing plan, archive the previous content
60
71
  archived_content = existing_plan ? archive_previous_plan(number, existing_plan) : nil
61
72
 
62
- comment_body = build_comment(issue: issue, plan: plan_data, label_actor: label_actor, archived_content: archived_content)
73
+ comment_body = build_comment(
74
+ issue: issue,
75
+ plan: plan_data,
76
+ project_mode: project_mode,
77
+ label_actor: label_actor,
78
+ archived_content: archived_content
79
+ )
63
80
  comment_body_with_feedback = FeedbackCollector.append_feedback_prompt(comment_body)
64
81
  comment_id = nil
65
82
 
@@ -91,8 +108,16 @@ module Aidp
91
108
  plan_data = plan_data.merge(comment_id: comment_id) if comment_id
92
109
  @state_store.record_plan(number, plan_data.merge(comment_body: comment_body, comment_hint: COMMENT_HEADER))
93
110
 
111
+ project_setup = project_mode ? process_project_plan(issue, plan_data) : {status: :not_applicable}
112
+
94
113
  # Update labels: remove plan trigger, add appropriate status label
95
- update_labels_after_plan(number, plan_data)
114
+ update_labels_after_plan(
115
+ number,
116
+ plan_data,
117
+ trigger_label: trigger_label,
118
+ project_mode: project_mode,
119
+ project_setup: project_setup
120
+ )
96
121
  end
97
122
 
98
123
  private
@@ -122,28 +147,34 @@ module Aidp
122
147
  archived_parts.join("\n")
123
148
  end
124
149
 
125
- def update_labels_after_plan(number, plan_data)
126
- questions = Array(plan_data[:questions])
127
- has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
128
-
129
- # Determine which label to add based on whether there are questions
130
- new_label = has_questions ? @needs_input_label : @ready_label
131
- status_text = has_questions ? "needs input" : "ready to build"
150
+ def update_labels_after_plan(number, plan_data, trigger_label:, project_mode:, project_setup:)
151
+ new_label, status_text = next_label_for(
152
+ plan_data,
153
+ trigger_label: trigger_label,
154
+ project_mode: project_mode,
155
+ project_setup: project_setup
156
+ )
157
+ old_labels = labels_to_replace(trigger_label, project_mode: project_mode)
158
+
159
+ if old_labels == [new_label]
160
+ display_message("๐Ÿท๏ธ Left label '#{trigger_label}' in place (#{status_text})", type: :info)
161
+ return
162
+ end
132
163
 
133
164
  begin
134
165
  @repository_client.replace_labels(
135
166
  number,
136
- old_labels: [@plan_label],
167
+ old_labels: old_labels,
137
168
  new_labels: [new_label]
138
169
  )
139
- display_message("๐Ÿท๏ธ Updated labels: removed '#{@plan_label}', added '#{new_label}' (#{status_text})", type: :info)
170
+ display_message("๐Ÿท๏ธ Updated labels: removed '#{trigger_label}', added '#{new_label}' (#{status_text})", type: :info)
140
171
  rescue => e
141
172
  display_message("โš ๏ธ Failed to update labels for issue ##{number}: #{e.message}", type: :warn)
142
173
  # Don't fail the whole process if label update fails
143
174
  end
144
175
  end
145
176
 
146
- def build_comment(issue:, plan:, label_actor: nil, archived_content: nil)
177
+ def build_comment(issue:, plan:, project_mode:, label_actor: nil, archived_content: nil)
147
178
  summary = plan[:summary].to_s.strip
148
179
  tasks = Array(plan[:tasks])
149
180
  questions = Array(plan[:questions])
@@ -187,7 +218,10 @@ module Aidp
187
218
 
188
219
  # Add instructions based on whether there are questions
189
220
  parts << if has_questions
190
- "**Next Steps**: Please reply with answers to the questions above. Once resolved, remove the `#{@needs_input_label}` label and add the `#{@build_label}` label to begin implementation."
221
+ resume_label = project_mode ? @project_label : @build_label
222
+ "**Next Steps**: Please reply with answers to the questions above. Once resolved, remove the `#{@needs_input_label}` label and add the `#{resume_label}` label to continue."
223
+ elsif plan[:should_create_sub_issues]
224
+ "**Next Steps**: AIDP will create project sub-issues, place them on the active GitHub Project, and dispatch dependency-ready work with `#{@build_label}` while blocked work is held under `#{@blocked_label}`."
191
225
  else
192
226
  "**Next Steps**: This plan is ready for implementation. Add the `#{@build_label}` label to begin."
193
227
  end
@@ -195,6 +229,192 @@ module Aidp
195
229
  parts.join("\n")
196
230
  end
197
231
 
232
+ def process_project_plan(issue, plan_data)
233
+ return {status: :not_required} unless plan_data[:should_create_sub_issues]
234
+
235
+ sub_issues = Array(plan_data[:sub_issues])
236
+ if sub_issues.empty?
237
+ return record_project_setup_failure(issue[:number], "no sub-issues were generated for project planning")
238
+ end
239
+
240
+ project_id = resolve_project_id(issue)
241
+ return record_project_setup_failure(issue[:number], "unable to resolve a GitHub Project") unless project_id
242
+
243
+ projects_processor = ProjectsProcessor.new(
244
+ repository_client: @repository_client,
245
+ state_store: @state_store,
246
+ project_id: project_id,
247
+ config: @project_config
248
+ )
249
+ unless projects_processor.ensure_project_fields
250
+ return record_project_setup_failure(issue[:number], "unable to configure required GitHub Project fields")
251
+ end
252
+
253
+ creator = SubIssueCreator.new(
254
+ repository_client: @repository_client,
255
+ state_store: @state_store,
256
+ project_id: project_id,
257
+ build_label: @build_label,
258
+ blocked_label: @blocked_label
259
+ )
260
+ created_issues = existing_sub_issues_for(issue, sub_issues: sub_issues, creator: creator)
261
+ created_issues = creator.create_sub_issues(issue, sub_issues) if created_issues.empty?
262
+ if created_issues.empty?
263
+ return record_project_setup_failure(issue[:number], "unable to create project sub-issues")
264
+ end
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
+
270
+ unless sync_project_issue_statuses(issue[:number], created_issues, projects_processor)
271
+ return record_project_setup_failure(issue[:number], "unable to sync project issues to the GitHub Project")
272
+ end
273
+
274
+ @state_store.record_project_sync(issue[:number], setup_status: "ready", setup_error: nil, setup_failed_at: nil)
275
+ {status: :ready}
276
+ rescue SubIssueCreator::UnresolvedDependenciesError => e
277
+ Aidp.log_error("plan_processor", "project_sub_issue_dependency_resolution_failed",
278
+ issue: issue[:number], error: e.message)
279
+ record_project_setup_failure(issue[:number], e.message)
280
+ rescue TrackedSubIssuesRetirementError => e
281
+ Aidp.log_error("plan_processor", "project_sub_issue_retirement_failed",
282
+ issue: issue[:number], error: e.message)
283
+ record_project_setup_failure(issue[:number], e.message)
284
+ end
285
+
286
+ def resolve_project_id(issue)
287
+ configured_project_id = @project_config[:default_project_id] || @project_config["default_project_id"]
288
+ return configured_project_id if configured_project_id
289
+
290
+ saved_project_id = @state_store.project_sync_data(issue[:number])["project_id"]
291
+ return saved_project_id if saved_project_id
292
+
293
+ active_project = @repository_client.find_active_project
294
+ if active_project
295
+ @state_store.record_project_sync(
296
+ issue[:number],
297
+ project_id: active_project[:id],
298
+ project_url: active_project[:url],
299
+ project_title: active_project[:title]
300
+ )
301
+ display_message("๐Ÿ“Š Reusing active GitHub Project '#{active_project[:title]}'", type: :info)
302
+ return active_project[:id]
303
+ end
304
+
305
+ title = "AIDP Project ##{issue[:number]}: #{issue[:title]}".slice(0, 100)
306
+ project = @repository_client.create_project(title: title)
307
+ @state_store.record_project_sync(issue[:number], project_id: project[:id], project_url: project[:url], project_title: project[:title])
308
+ display_message("๐Ÿ“Š Created GitHub Project '#{project[:title]}'", type: :success)
309
+ project[:id]
310
+ rescue => e
311
+ Aidp.log_error("plan_processor", "project_resolution_failed", issue: issue[:number], error: e.message)
312
+ display_message("โš ๏ธ Failed to resolve GitHub Project for issue ##{issue[:number]}: #{e.message}", type: :warn)
313
+ nil
314
+ end
315
+
316
+ def sync_project_issue_statuses(parent_number, created_issues, projects_processor)
317
+ return false unless projects_processor.sync_issue_to_project(parent_number, status: ProjectsProcessor::STATUS_VALUES[:blocked])
318
+
319
+ created_issues.each do |created_issue|
320
+ status = project_issue_status_for(created_issue)
321
+ return false unless projects_processor.sync_issue_to_project(created_issue[:number], status: status)
322
+ end
323
+
324
+ true
325
+ end
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
+
345
+ def existing_sub_issues_for(parent_issue, sub_issues:, creator:)
346
+ parent_number = parent_issue[:number]
347
+ existing_numbers = @state_store.sub_issues(parent_number)
348
+ return [] if existing_numbers.empty?
349
+
350
+ tracked_issues = existing_numbers.map { |number| fetch_tracked_sub_issue(number) }
351
+ preserved_issues, remaining_numbers, remaining_sub_issues = preserve_closed_tracked_sub_issues(tracked_issues, sub_issues)
352
+ preserved_numbers_by_title = issue_numbers_by_title(preserved_issues)
353
+
354
+ remaining_issues = if remaining_numbers.empty? && remaining_sub_issues.empty?
355
+ []
356
+ elsif reconciliable_sub_issues?(remaining_numbers, remaining_sub_issues)
357
+ display_message("๐Ÿ” Reusing #{existing_numbers.size} tracked sub-issues for ##{parent_number}", type: :info)
358
+ reconcile_existing_sub_issues(
359
+ parent_issue,
360
+ remaining_numbers,
361
+ remaining_sub_issues,
362
+ creator,
363
+ issue_numbers_by_title: preserved_numbers_by_title
364
+ )
365
+ else
366
+ regenerate_open_tracked_sub_issues(
367
+ parent_issue,
368
+ parent_number,
369
+ remaining_numbers,
370
+ remaining_sub_issues,
371
+ creator,
372
+ preserved_numbers_by_title: preserved_numbers_by_title
373
+ )
374
+ end
375
+
376
+ ordered_issues = order_tracked_sub_issues(sub_issues, preserved_issues + remaining_issues)
377
+ record_tracked_sub_issue_dependencies(ordered_issues, sub_issues)
378
+ @state_store.record_sub_issues(parent_number, ordered_issues.map { |tracked_issue| tracked_issue[:number] })
379
+ ordered_issues
380
+ end
381
+
382
+ def next_label_for(plan_data, trigger_label:, project_mode:, project_setup:)
383
+ questions = Array(plan_data[:questions])
384
+ has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
385
+
386
+ return [@needs_input_label, "needs input"] if has_questions
387
+ if project_mode && plan_data[:should_create_sub_issues]
388
+ return [@blocked_label, "project initialized"] if project_setup[:status] == :ready
389
+
390
+ return [@needs_input_label, "project setup failed: #{project_setup[:reason]}"]
391
+ end
392
+
393
+ [@ready_label, "ready to build"]
394
+ end
395
+
396
+ def labels_to_replace(trigger_label, project_mode:)
397
+ return [trigger_label] unless project_mode
398
+
399
+ [trigger_label, @plan_label, @build_label].uniq
400
+ end
401
+
402
+ def record_project_setup_failure(issue_number, reason)
403
+ @state_store.record_project_sync(
404
+ issue_number,
405
+ setup_status: "failed",
406
+ setup_error: reason,
407
+ setup_failed_at: Time.now.utc.iso8601
408
+ )
409
+ {status: :failed, reason: reason}
410
+ end
411
+
412
+ def project_issue_status_for(created_issue)
413
+ return ProjectsProcessor::STATUS_VALUES[:done] if created_issue[:state].to_s.casecmp("closed").zero?
414
+
415
+ created_issue[:dependencies].any? ? ProjectsProcessor::STATUS_VALUES[:blocked] : ProjectsProcessor::STATUS_VALUES[:todo]
416
+ end
417
+
198
418
  def format_bullets(items, placeholder:)
199
419
  if items.empty?
200
420
  placeholder
@@ -210,6 +430,262 @@ module Aidp
210
430
  items.each_with_index.map { |item, index| "#{index + 1}. #{item}" }.join("\n")
211
431
  end
212
432
  end
433
+
434
+ def reconciliable_sub_issues?(existing_numbers, sub_issues)
435
+ return false unless sub_issue_count_matches?(existing_numbers, sub_issues)
436
+ return true if tracked_sub_issues_match_plan?(existing_numbers, sub_issues)
437
+
438
+ display_message(
439
+ "โš ๏ธ Tracked sub-issues no longer match the regenerated plan; regenerating them",
440
+ type: :warn
441
+ )
442
+ false
443
+ end
444
+
445
+ def sub_issue_count_matches?(existing_numbers, sub_issues)
446
+ return true if existing_numbers.size == sub_issues.size
447
+
448
+ display_message(
449
+ "โš ๏ธ Tracked sub-issues no longer match the regenerated plan (#{existing_numbers.size} tracked, #{sub_issues.size} planned); regenerating them",
450
+ type: :warn
451
+ )
452
+ false
453
+ end
454
+
455
+ def tracked_sub_issues_match_plan?(existing_numbers, sub_issues)
456
+ tracked_issues = existing_numbers.map { |number| fetch_tracked_sub_issue(number) }
457
+
458
+ tracked_titles = tracked_issues.map { |tracked_issue| normalize_sub_issue_title(tracked_issue[:title]) }
459
+ planned_titles = sub_issues.map { |sub_issue| normalize_sub_issue_title(sub_issue[:title]) }
460
+
461
+ tracked_titles == planned_titles
462
+ rescue => e
463
+ Aidp.log_warn("plan_processor", "tracked_sub_issue_validation_failed",
464
+ error: e.message,
465
+ tracked_issue_numbers: existing_numbers)
466
+ false
467
+ end
468
+
469
+ def fetch_tracked_sub_issue(number)
470
+ @repository_client.fetch_issue(number)
471
+ end
472
+
473
+ def normalize_sub_issue_title(title)
474
+ title.to_s.strip.downcase
475
+ end
476
+
477
+ def preserve_closed_tracked_sub_issues(tracked_issues, sub_issues)
478
+ remaining_tracked_issues = tracked_issues.dup
479
+ remaining_sub_issues = sub_issues.dup
480
+ preserved_issues = []
481
+
482
+ tracked_issues.each do |tracked_issue|
483
+ next unless tracked_issue[:state].to_s.casecmp("closed").zero?
484
+
485
+ match_index = remaining_sub_issues.index do |sub_issue|
486
+ normalize_sub_issue_title(sub_issue[:title]) == normalize_sub_issue_title(tracked_issue[:title])
487
+ end
488
+ next unless match_index
489
+
490
+ preserved_issues << tracked_issue
491
+ remaining_tracked_issues.delete(tracked_issue)
492
+ remaining_sub_issues.delete_at(match_index)
493
+ end
494
+
495
+ return [preserved_issues, remaining_tracked_issues.map { |tracked_issue| tracked_issue[:number] }, remaining_sub_issues] if preserved_issues.empty?
496
+
497
+ suffix = if preserved_issues.one?
498
+ ""
499
+ else
500
+ "s"
501
+ end
502
+ display_message(
503
+ "๐Ÿ”’ Preserving #{preserved_issues.size} completed tracked sub-issue#{suffix} during re-plan",
504
+ type: :info
505
+ )
506
+ [preserved_issues, remaining_tracked_issues.map { |tracked_issue| tracked_issue[:number] }, remaining_sub_issues]
507
+ end
508
+
509
+ def reconcile_existing_sub_issues(parent_issue, existing_numbers, sub_issues, creator, issue_numbers_by_title: {})
510
+ tracked_issues = sub_issues.each_with_index.map do |sub_issue, index|
511
+ number = existing_numbers.fetch(index)
512
+ tracked_issue = @repository_client.fetch_issue(number)
513
+ planned_issue = creator.planned_sub_issue_attributes(parent_issue, sub_issue, index + 1)
514
+ update_tracked_sub_issue(number, tracked_issue, planned_issue) unless tracked_sub_issue_contract_matches?(tracked_issue, planned_issue)
515
+
516
+ {
517
+ number: number,
518
+ title: planned_issue[:title].to_s.strip,
519
+ dependencies: Array(sub_issue[:dependencies]),
520
+ state: tracked_issue[:state]
521
+ }
522
+ end
523
+
524
+ issue_numbers_by_title = build_issue_numbers_by_title(tracked_issues, issue_numbers_by_title)
525
+ tracked_issues.each do |tracked_issue|
526
+ dependencies, unresolved_dependencies = resolve_tracked_dependencies(
527
+ tracked_issue[:dependencies],
528
+ issue_numbers_by_title
529
+ )
530
+ if unresolved_dependencies.any?
531
+ raise SubIssueCreator::UnresolvedDependenciesError,
532
+ "Unable to resolve dependencies for sub-issue ##{tracked_issue[:number]} (#{tracked_issue[:title]}): #{unresolved_dependencies.join(", ")}"
533
+ end
534
+
535
+ @state_store.record_issue_dependencies(tracked_issue[:number], dependencies)
536
+ tracked_issue[:dependencies] = dependencies
537
+ end
538
+
539
+ tracked_issues
540
+ end
541
+
542
+ def regenerate_open_tracked_sub_issues(parent_issue, parent_number, existing_numbers, sub_issues, creator, preserved_numbers_by_title:)
543
+ retire_tracked_sub_issues(parent_number, existing_numbers)
544
+ return [] if sub_issues.empty?
545
+
546
+ creator.create_sub_issues(
547
+ parent_issue,
548
+ sub_issues_with_resolved_dependencies(sub_issues, preserved_numbers_by_title)
549
+ )
550
+ end
551
+
552
+ def order_tracked_sub_issues(sub_issues, tracked_issues)
553
+ tracked_issue_groups = tracked_issues.group_by { |tracked_issue| normalize_sub_issue_title(tracked_issue[:title]) }
554
+
555
+ sub_issues.map do |sub_issue|
556
+ normalized_title = normalize_sub_issue_title(sub_issue[:title])
557
+ issue_group = tracked_issue_groups[normalized_title]
558
+ next issue_group.shift if issue_group&.any?
559
+
560
+ raise SubIssueCreator::UnresolvedDependenciesError,
561
+ "Unable to match tracked sub-issue for planned item '#{sub_issue[:title]}'"
562
+ end
563
+ end
564
+
565
+ def record_tracked_sub_issue_dependencies(tracked_issues, sub_issues)
566
+ issue_numbers_by_title = build_issue_numbers_by_title(tracked_issues)
567
+
568
+ tracked_issues.zip(sub_issues).each do |tracked_issue, sub_issue|
569
+ dependencies, unresolved_dependencies = resolve_tracked_dependencies(
570
+ sub_issue[:dependencies],
571
+ issue_numbers_by_title
572
+ )
573
+ if unresolved_dependencies.any?
574
+ raise SubIssueCreator::UnresolvedDependenciesError,
575
+ "Unable to resolve dependencies for sub-issue ##{tracked_issue[:number]} (#{tracked_issue[:title]}): #{unresolved_dependencies.join(", ")}"
576
+ end
577
+
578
+ @state_store.record_issue_dependencies(tracked_issue[:number], dependencies)
579
+ tracked_issue[:dependencies] = dependencies
580
+ end
581
+ end
582
+
583
+ def sub_issues_with_resolved_dependencies(sub_issues, issue_numbers_by_title)
584
+ sub_issues.map do |sub_issue|
585
+ dependencies = Array(sub_issue[:dependencies]).map do |dependency|
586
+ issue_number = issue_numbers_by_title[normalize_sub_issue_title(dependency)]
587
+ issue_number ? "##{issue_number}" : dependency
588
+ end
589
+
590
+ sub_issue.merge(dependencies: dependencies)
591
+ end
592
+ end
593
+
594
+ def build_issue_numbers_by_title(tracked_issues, seed = {})
595
+ issue_numbers_by_title = seed.dup
596
+ duplicate_titles = duplicate_normalized_tracked_titles(tracked_issues)
597
+ if duplicate_titles.empty?
598
+ tracked_issues.each do |tracked_issue|
599
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
600
+ next if normalized_title.empty?
601
+ next unless issue_numbers_by_title.key?(normalized_title) && issue_numbers_by_title[normalized_title] != tracked_issue[:number]
602
+
603
+ duplicate_titles << tracked_issue[:title].to_s.strip
604
+ end
605
+ if duplicate_titles.empty?
606
+ return tracked_issues.each_with_object(issue_numbers_by_title) do |tracked_issue, memo|
607
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
608
+ memo[normalized_title] = tracked_issue[:number] unless normalized_title.empty?
609
+ end
610
+ end
611
+ end
612
+
613
+ raise SubIssueCreator::UnresolvedDependenciesError,
614
+ "Duplicate sub-issue titles after normalization are not allowed: #{duplicate_titles.join(", ")}"
615
+ end
616
+
617
+ def duplicate_normalized_tracked_titles(tracked_issues)
618
+ grouped_titles = tracked_issues.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |tracked_issue, memo|
619
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
620
+ next if normalized_title.empty?
621
+
622
+ memo[normalized_title] << tracked_issue[:title].to_s.strip
623
+ end
624
+
625
+ grouped_titles.filter_map do |_normalized_title, titles|
626
+ next unless titles.size > 1
627
+
628
+ titles.uniq.join(" / ")
629
+ end
630
+ end
631
+
632
+ def issue_numbers_by_title(tracked_issues)
633
+ tracked_issues.each_with_object({}) do |tracked_issue, memo|
634
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
635
+ memo[normalized_title] = tracked_issue[:number] unless normalized_title.empty?
636
+ end
637
+ end
638
+
639
+ def resolve_tracked_dependencies(dependencies, issue_numbers_by_title)
640
+ Array(dependencies).each_with_object([[], []]) do |dependency, (resolved, unresolved)|
641
+ text = dependency.to_s.strip
642
+ match = text.match(/\A#(\d+)\z/)
643
+ resolved_dependency = match ? match[1].to_i : issue_numbers_by_title[normalize_sub_issue_title(text)]
644
+ resolved_dependency ? resolved << resolved_dependency : unresolved << dependency
645
+ end
646
+ end
647
+
648
+ def tracked_sub_issue_contract_matches?(tracked_issue, planned_issue)
649
+ normalize_sub_issue_title(tracked_issue[:title]) == normalize_sub_issue_title(planned_issue[:title]) &&
650
+ tracked_issue[:body].to_s == planned_issue[:body] &&
651
+ normalized_list(tracked_issue[:labels]) == normalized_list(planned_issue[:labels]) &&
652
+ normalized_list(tracked_issue[:assignees]) == normalized_list(planned_issue[:assignees])
653
+ end
654
+
655
+ def update_tracked_sub_issue(number, tracked_issue, planned_issue)
656
+ Aidp.log_info("plan_processor", "tracked_sub_issue_updated",
657
+ issue: number,
658
+ title_changed: normalize_sub_issue_title(tracked_issue[:title]) != normalize_sub_issue_title(planned_issue[:title]),
659
+ body_changed: tracked_issue[:body].to_s != planned_issue[:body],
660
+ labels_changed: normalized_list(tracked_issue[:labels]) != normalized_list(planned_issue[:labels]),
661
+ assignees_changed: normalized_list(tracked_issue[:assignees]) != normalized_list(planned_issue[:assignees]))
662
+
663
+ @repository_client.update_issue(
664
+ number,
665
+ title: planned_issue[:title],
666
+ body: planned_issue[:body],
667
+ labels: planned_issue[:labels],
668
+ assignees: planned_issue[:assignees]
669
+ )
670
+ end
671
+
672
+ def normalized_list(values)
673
+ Array(values).map { |value| value.to_s.strip }.reject(&:empty?).sort
674
+ end
675
+
676
+ def retire_tracked_sub_issues(parent_number, existing_numbers)
677
+ display_message("๐Ÿงน Retiring #{existing_numbers.size} stale tracked sub-issues for ##{parent_number}", type: :warn)
678
+
679
+ existing_numbers.each do |number|
680
+ @repository_client.close_issue(number)
681
+ rescue => e
682
+ raise TrackedSubIssuesRetirementError,
683
+ "unable to retire stale tracked sub-issue ##{number}: #{e.message}"
684
+ end
685
+
686
+ @state_store.clear_sub_issues(parent_number)
687
+ []
688
+ end
213
689
  end
214
690
  end
215
691
  end