aidp 0.42.0 โ†’ 0.44.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,170 @@ 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
+ unless sync_project_issue_statuses(issue[:number], created_issues, projects_processor)
267
+ return record_project_setup_failure(issue[:number], "unable to sync project issues to the GitHub Project")
268
+ end
269
+
270
+ @state_store.record_project_sync(issue[:number], setup_status: "ready", setup_error: nil, setup_failed_at: nil)
271
+ {status: :ready}
272
+ rescue SubIssueCreator::UnresolvedDependenciesError => e
273
+ Aidp.log_error("plan_processor", "project_sub_issue_dependency_resolution_failed",
274
+ issue: issue[:number], error: e.message)
275
+ record_project_setup_failure(issue[:number], e.message)
276
+ rescue TrackedSubIssuesRetirementError => e
277
+ Aidp.log_error("plan_processor", "project_sub_issue_retirement_failed",
278
+ issue: issue[:number], error: e.message)
279
+ record_project_setup_failure(issue[:number], e.message)
280
+ end
281
+
282
+ def resolve_project_id(issue)
283
+ configured_project_id = @project_config[:default_project_id] || @project_config["default_project_id"]
284
+ return configured_project_id if configured_project_id
285
+
286
+ saved_project_id = @state_store.project_sync_data(issue[:number])["project_id"]
287
+ return saved_project_id if saved_project_id
288
+
289
+ active_project = @repository_client.find_active_project
290
+ if active_project
291
+ @state_store.record_project_sync(
292
+ issue[:number],
293
+ project_id: active_project[:id],
294
+ project_url: active_project[:url],
295
+ project_title: active_project[:title]
296
+ )
297
+ display_message("๐Ÿ“Š Reusing active GitHub Project '#{active_project[:title]}'", type: :info)
298
+ return active_project[:id]
299
+ end
300
+
301
+ title = "AIDP Project ##{issue[:number]}: #{issue[:title]}".slice(0, 100)
302
+ project = @repository_client.create_project(title: title)
303
+ @state_store.record_project_sync(issue[:number], project_id: project[:id], project_url: project[:url], project_title: project[:title])
304
+ display_message("๐Ÿ“Š Created GitHub Project '#{project[:title]}'", type: :success)
305
+ project[:id]
306
+ rescue => e
307
+ Aidp.log_error("plan_processor", "project_resolution_failed", issue: issue[:number], error: e.message)
308
+ display_message("โš ๏ธ Failed to resolve GitHub Project for issue ##{issue[:number]}: #{e.message}", type: :warn)
309
+ nil
310
+ end
311
+
312
+ def sync_project_issue_statuses(parent_number, created_issues, projects_processor)
313
+ return false unless projects_processor.sync_issue_to_project(parent_number, status: ProjectsProcessor::STATUS_VALUES[:blocked])
314
+
315
+ created_issues.each do |created_issue|
316
+ status = project_issue_status_for(created_issue)
317
+ return false unless projects_processor.sync_issue_to_project(created_issue[:number], status: status)
318
+ end
319
+
320
+ true
321
+ end
322
+
323
+ def existing_sub_issues_for(parent_issue, sub_issues:, creator:)
324
+ parent_number = parent_issue[:number]
325
+ existing_numbers = @state_store.sub_issues(parent_number)
326
+ return [] if existing_numbers.empty?
327
+
328
+ tracked_issues = existing_numbers.map { |number| fetch_tracked_sub_issue(number) }
329
+ preserved_issues, remaining_numbers, remaining_sub_issues = preserve_closed_tracked_sub_issues(tracked_issues, sub_issues)
330
+ preserved_numbers_by_title = issue_numbers_by_title(preserved_issues)
331
+
332
+ remaining_issues = if remaining_numbers.empty? && remaining_sub_issues.empty?
333
+ []
334
+ elsif reconciliable_sub_issues?(remaining_numbers, remaining_sub_issues)
335
+ display_message("๐Ÿ” Reusing #{existing_numbers.size} tracked sub-issues for ##{parent_number}", type: :info)
336
+ reconcile_existing_sub_issues(
337
+ parent_issue,
338
+ remaining_numbers,
339
+ remaining_sub_issues,
340
+ creator,
341
+ issue_numbers_by_title: preserved_numbers_by_title
342
+ )
343
+ else
344
+ regenerate_open_tracked_sub_issues(
345
+ parent_issue,
346
+ parent_number,
347
+ remaining_numbers,
348
+ remaining_sub_issues,
349
+ creator,
350
+ preserved_numbers_by_title: preserved_numbers_by_title
351
+ )
352
+ end
353
+
354
+ ordered_issues = order_tracked_sub_issues(sub_issues, preserved_issues + remaining_issues)
355
+ record_tracked_sub_issue_dependencies(ordered_issues, sub_issues)
356
+ @state_store.record_sub_issues(parent_number, ordered_issues.map { |tracked_issue| tracked_issue[:number] })
357
+ ordered_issues
358
+ end
359
+
360
+ def next_label_for(plan_data, trigger_label:, project_mode:, project_setup:)
361
+ questions = Array(plan_data[:questions])
362
+ has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
363
+
364
+ return [@needs_input_label, "needs input"] if has_questions
365
+ if project_mode && plan_data[:should_create_sub_issues]
366
+ return [@blocked_label, "project initialized"] if project_setup[:status] == :ready
367
+
368
+ return [@needs_input_label, "project setup failed: #{project_setup[:reason]}"]
369
+ end
370
+
371
+ [@ready_label, "ready to build"]
372
+ end
373
+
374
+ def labels_to_replace(trigger_label, project_mode:)
375
+ return [trigger_label] unless project_mode
376
+
377
+ [trigger_label, @plan_label, @build_label].uniq
378
+ end
379
+
380
+ def record_project_setup_failure(issue_number, reason)
381
+ @state_store.record_project_sync(
382
+ issue_number,
383
+ setup_status: "failed",
384
+ setup_error: reason,
385
+ setup_failed_at: Time.now.utc.iso8601
386
+ )
387
+ {status: :failed, reason: reason}
388
+ end
389
+
390
+ def project_issue_status_for(created_issue)
391
+ return ProjectsProcessor::STATUS_VALUES[:done] if created_issue[:state].to_s.casecmp("closed").zero?
392
+
393
+ created_issue[:dependencies].any? ? ProjectsProcessor::STATUS_VALUES[:blocked] : ProjectsProcessor::STATUS_VALUES[:todo]
394
+ end
395
+
198
396
  def format_bullets(items, placeholder:)
199
397
  if items.empty?
200
398
  placeholder
@@ -210,6 +408,262 @@ module Aidp
210
408
  items.each_with_index.map { |item, index| "#{index + 1}. #{item}" }.join("\n")
211
409
  end
212
410
  end
411
+
412
+ def reconciliable_sub_issues?(existing_numbers, sub_issues)
413
+ return false unless sub_issue_count_matches?(existing_numbers, sub_issues)
414
+ return true if tracked_sub_issues_match_plan?(existing_numbers, sub_issues)
415
+
416
+ display_message(
417
+ "โš ๏ธ Tracked sub-issues no longer match the regenerated plan; regenerating them",
418
+ type: :warn
419
+ )
420
+ false
421
+ end
422
+
423
+ def sub_issue_count_matches?(existing_numbers, sub_issues)
424
+ return true if existing_numbers.size == sub_issues.size
425
+
426
+ display_message(
427
+ "โš ๏ธ Tracked sub-issues no longer match the regenerated plan (#{existing_numbers.size} tracked, #{sub_issues.size} planned); regenerating them",
428
+ type: :warn
429
+ )
430
+ false
431
+ end
432
+
433
+ def tracked_sub_issues_match_plan?(existing_numbers, sub_issues)
434
+ tracked_issues = existing_numbers.map { |number| fetch_tracked_sub_issue(number) }
435
+
436
+ tracked_titles = tracked_issues.map { |tracked_issue| normalize_sub_issue_title(tracked_issue[:title]) }
437
+ planned_titles = sub_issues.map { |sub_issue| normalize_sub_issue_title(sub_issue[:title]) }
438
+
439
+ tracked_titles == planned_titles
440
+ rescue => e
441
+ Aidp.log_warn("plan_processor", "tracked_sub_issue_validation_failed",
442
+ error: e.message,
443
+ tracked_issue_numbers: existing_numbers)
444
+ false
445
+ end
446
+
447
+ def fetch_tracked_sub_issue(number)
448
+ @repository_client.fetch_issue(number)
449
+ end
450
+
451
+ def normalize_sub_issue_title(title)
452
+ title.to_s.strip.downcase
453
+ end
454
+
455
+ def preserve_closed_tracked_sub_issues(tracked_issues, sub_issues)
456
+ remaining_tracked_issues = tracked_issues.dup
457
+ remaining_sub_issues = sub_issues.dup
458
+ preserved_issues = []
459
+
460
+ tracked_issues.each do |tracked_issue|
461
+ next unless tracked_issue[:state].to_s.casecmp("closed").zero?
462
+
463
+ match_index = remaining_sub_issues.index do |sub_issue|
464
+ normalize_sub_issue_title(sub_issue[:title]) == normalize_sub_issue_title(tracked_issue[:title])
465
+ end
466
+ next unless match_index
467
+
468
+ preserved_issues << tracked_issue
469
+ remaining_tracked_issues.delete(tracked_issue)
470
+ remaining_sub_issues.delete_at(match_index)
471
+ end
472
+
473
+ return [preserved_issues, remaining_tracked_issues.map { |tracked_issue| tracked_issue[:number] }, remaining_sub_issues] if preserved_issues.empty?
474
+
475
+ suffix = if preserved_issues.one?
476
+ ""
477
+ else
478
+ "s"
479
+ end
480
+ display_message(
481
+ "๐Ÿ”’ Preserving #{preserved_issues.size} completed tracked sub-issue#{suffix} during re-plan",
482
+ type: :info
483
+ )
484
+ [preserved_issues, remaining_tracked_issues.map { |tracked_issue| tracked_issue[:number] }, remaining_sub_issues]
485
+ end
486
+
487
+ def reconcile_existing_sub_issues(parent_issue, existing_numbers, sub_issues, creator, issue_numbers_by_title: {})
488
+ tracked_issues = sub_issues.each_with_index.map do |sub_issue, index|
489
+ number = existing_numbers.fetch(index)
490
+ tracked_issue = @repository_client.fetch_issue(number)
491
+ planned_issue = creator.planned_sub_issue_attributes(parent_issue, sub_issue, index + 1)
492
+ update_tracked_sub_issue(number, tracked_issue, planned_issue) unless tracked_sub_issue_contract_matches?(tracked_issue, planned_issue)
493
+
494
+ {
495
+ number: number,
496
+ title: planned_issue[:title].to_s.strip,
497
+ dependencies: Array(sub_issue[:dependencies]),
498
+ state: tracked_issue[:state]
499
+ }
500
+ end
501
+
502
+ issue_numbers_by_title = build_issue_numbers_by_title(tracked_issues, issue_numbers_by_title)
503
+ tracked_issues.each do |tracked_issue|
504
+ dependencies, unresolved_dependencies = resolve_tracked_dependencies(
505
+ tracked_issue[:dependencies],
506
+ issue_numbers_by_title
507
+ )
508
+ if unresolved_dependencies.any?
509
+ raise SubIssueCreator::UnresolvedDependenciesError,
510
+ "Unable to resolve dependencies for sub-issue ##{tracked_issue[:number]} (#{tracked_issue[:title]}): #{unresolved_dependencies.join(", ")}"
511
+ end
512
+
513
+ @state_store.record_issue_dependencies(tracked_issue[:number], dependencies)
514
+ tracked_issue[:dependencies] = dependencies
515
+ end
516
+
517
+ tracked_issues
518
+ end
519
+
520
+ def regenerate_open_tracked_sub_issues(parent_issue, parent_number, existing_numbers, sub_issues, creator, preserved_numbers_by_title:)
521
+ retire_tracked_sub_issues(parent_number, existing_numbers)
522
+ return [] if sub_issues.empty?
523
+
524
+ creator.create_sub_issues(
525
+ parent_issue,
526
+ sub_issues_with_resolved_dependencies(sub_issues, preserved_numbers_by_title)
527
+ )
528
+ end
529
+
530
+ def order_tracked_sub_issues(sub_issues, tracked_issues)
531
+ tracked_issue_groups = tracked_issues.group_by { |tracked_issue| normalize_sub_issue_title(tracked_issue[:title]) }
532
+
533
+ sub_issues.map do |sub_issue|
534
+ normalized_title = normalize_sub_issue_title(sub_issue[:title])
535
+ issue_group = tracked_issue_groups[normalized_title]
536
+ next issue_group.shift if issue_group&.any?
537
+
538
+ raise SubIssueCreator::UnresolvedDependenciesError,
539
+ "Unable to match tracked sub-issue for planned item '#{sub_issue[:title]}'"
540
+ end
541
+ end
542
+
543
+ def record_tracked_sub_issue_dependencies(tracked_issues, sub_issues)
544
+ issue_numbers_by_title = build_issue_numbers_by_title(tracked_issues)
545
+
546
+ tracked_issues.zip(sub_issues).each do |tracked_issue, sub_issue|
547
+ dependencies, unresolved_dependencies = resolve_tracked_dependencies(
548
+ sub_issue[:dependencies],
549
+ issue_numbers_by_title
550
+ )
551
+ if unresolved_dependencies.any?
552
+ raise SubIssueCreator::UnresolvedDependenciesError,
553
+ "Unable to resolve dependencies for sub-issue ##{tracked_issue[:number]} (#{tracked_issue[:title]}): #{unresolved_dependencies.join(", ")}"
554
+ end
555
+
556
+ @state_store.record_issue_dependencies(tracked_issue[:number], dependencies)
557
+ tracked_issue[:dependencies] = dependencies
558
+ end
559
+ end
560
+
561
+ def sub_issues_with_resolved_dependencies(sub_issues, issue_numbers_by_title)
562
+ sub_issues.map do |sub_issue|
563
+ dependencies = Array(sub_issue[:dependencies]).map do |dependency|
564
+ issue_number = issue_numbers_by_title[normalize_sub_issue_title(dependency)]
565
+ issue_number ? "##{issue_number}" : dependency
566
+ end
567
+
568
+ sub_issue.merge(dependencies: dependencies)
569
+ end
570
+ end
571
+
572
+ def build_issue_numbers_by_title(tracked_issues, seed = {})
573
+ issue_numbers_by_title = seed.dup
574
+ duplicate_titles = duplicate_normalized_tracked_titles(tracked_issues)
575
+ if duplicate_titles.empty?
576
+ tracked_issues.each do |tracked_issue|
577
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
578
+ next if normalized_title.empty?
579
+ next unless issue_numbers_by_title.key?(normalized_title) && issue_numbers_by_title[normalized_title] != tracked_issue[:number]
580
+
581
+ duplicate_titles << tracked_issue[:title].to_s.strip
582
+ end
583
+ if duplicate_titles.empty?
584
+ return tracked_issues.each_with_object(issue_numbers_by_title) do |tracked_issue, memo|
585
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
586
+ memo[normalized_title] = tracked_issue[:number] unless normalized_title.empty?
587
+ end
588
+ end
589
+ end
590
+
591
+ raise SubIssueCreator::UnresolvedDependenciesError,
592
+ "Duplicate sub-issue titles after normalization are not allowed: #{duplicate_titles.join(", ")}"
593
+ end
594
+
595
+ def duplicate_normalized_tracked_titles(tracked_issues)
596
+ grouped_titles = tracked_issues.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |tracked_issue, memo|
597
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
598
+ next if normalized_title.empty?
599
+
600
+ memo[normalized_title] << tracked_issue[:title].to_s.strip
601
+ end
602
+
603
+ grouped_titles.filter_map do |_normalized_title, titles|
604
+ next unless titles.size > 1
605
+
606
+ titles.uniq.join(" / ")
607
+ end
608
+ end
609
+
610
+ def issue_numbers_by_title(tracked_issues)
611
+ tracked_issues.each_with_object({}) do |tracked_issue, memo|
612
+ normalized_title = normalize_sub_issue_title(tracked_issue[:title])
613
+ memo[normalized_title] = tracked_issue[:number] unless normalized_title.empty?
614
+ end
615
+ end
616
+
617
+ def resolve_tracked_dependencies(dependencies, issue_numbers_by_title)
618
+ Array(dependencies).each_with_object([[], []]) do |dependency, (resolved, unresolved)|
619
+ text = dependency.to_s.strip
620
+ match = text.match(/\A#(\d+)\z/)
621
+ resolved_dependency = match ? match[1].to_i : issue_numbers_by_title[normalize_sub_issue_title(text)]
622
+ resolved_dependency ? resolved << resolved_dependency : unresolved << dependency
623
+ end
624
+ end
625
+
626
+ def tracked_sub_issue_contract_matches?(tracked_issue, planned_issue)
627
+ normalize_sub_issue_title(tracked_issue[:title]) == normalize_sub_issue_title(planned_issue[:title]) &&
628
+ tracked_issue[:body].to_s == planned_issue[:body] &&
629
+ normalized_list(tracked_issue[:labels]) == normalized_list(planned_issue[:labels]) &&
630
+ normalized_list(tracked_issue[:assignees]) == normalized_list(planned_issue[:assignees])
631
+ end
632
+
633
+ def update_tracked_sub_issue(number, tracked_issue, planned_issue)
634
+ Aidp.log_info("plan_processor", "tracked_sub_issue_updated",
635
+ issue: number,
636
+ title_changed: normalize_sub_issue_title(tracked_issue[:title]) != normalize_sub_issue_title(planned_issue[:title]),
637
+ body_changed: tracked_issue[:body].to_s != planned_issue[:body],
638
+ labels_changed: normalized_list(tracked_issue[:labels]) != normalized_list(planned_issue[:labels]),
639
+ assignees_changed: normalized_list(tracked_issue[:assignees]) != normalized_list(planned_issue[:assignees]))
640
+
641
+ @repository_client.update_issue(
642
+ number,
643
+ title: planned_issue[:title],
644
+ body: planned_issue[:body],
645
+ labels: planned_issue[:labels],
646
+ assignees: planned_issue[:assignees]
647
+ )
648
+ end
649
+
650
+ def normalized_list(values)
651
+ Array(values).map { |value| value.to_s.strip }.reject(&:empty?).sort
652
+ end
653
+
654
+ def retire_tracked_sub_issues(parent_number, existing_numbers)
655
+ display_message("๐Ÿงน Retiring #{existing_numbers.size} stale tracked sub-issues for ##{parent_number}", type: :warn)
656
+
657
+ existing_numbers.each do |number|
658
+ @repository_client.close_issue(number)
659
+ rescue => e
660
+ raise TrackedSubIssuesRetirementError,
661
+ "unable to retire stale tracked sub-issue ##{number}: #{e.message}"
662
+ end
663
+
664
+ @state_store.clear_sub_issues(parent_number)
665
+ []
666
+ end
213
667
  end
214
668
  end
215
669
  end