aidp 0.43.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 013dc044889cc320404776d0691d2091f3cddda0d9ba854dc0afb92312396336
4
- data.tar.gz: bb1c57bf8e7d87dfa7d7b169d69646ac76c0cb14fe2f72c70ef02b8543b43494
3
+ metadata.gz: d898c7dea670e05503b8777e0907e0d0f975d398fd3cf80d9ddc659e8806070c
4
+ data.tar.gz: c25d8e50f56ce0e89f93c3e5fea9d4fb0ca6d540c19ac5fd3d2710d38be57290
5
5
  SHA512:
6
- metadata.gz: c7ea760cd1d62424ada60f64acc356cdf4a82db6e2166c203d9d41c489fed936a704afaee17e9a20d588fbf0ead8ebd816a991a3fa01d87644939c872b104b12
7
- data.tar.gz: 9c3b34d7474a2f9e899bf50d9b61d1edcb67a1b4a7b41d78abf810517db8cb2082fe889ec7b1fda5266276b6f1406e577644f2ddca16142bcf66e66e79b1e338
6
+ metadata.gz: ec307da67468acc15043f72c0295f443a7650a4312d1767f12629fbf582d02354a76cf2476081a313cb434c48eb63f5ec06d6d7d90f39d00d972342725d0dbe5
7
+ data.tar.gz: 7bbb9cbfff2543d753b56ca940c19745b0b4678d9e4825c418639c5ec2cbeeac99069de846e75e8c8ef05a8d83b825d13d7e99b056a462e07a89c1f56a2cb96a
data/lib/aidp/config.rb CHANGED
@@ -197,7 +197,9 @@ module Aidp
197
197
  polling_interval: 30,
198
198
  labels: {
199
199
  plan_trigger: "aidp-plan",
200
+ project_trigger: "aidp-project",
200
201
  build_trigger: "aidp-build",
202
+ blocked_trigger: "aidp-blocked",
201
203
  review_trigger: "aidp-review",
202
204
  fix_ci_trigger: "aidp-fix-ci",
203
205
  change_request_trigger: "aidp-request-changes",
@@ -1649,6 +1649,11 @@ module Aidp
1649
1649
  existing[:plan_trigger] || "aidp-plan"
1650
1650
  )
1651
1651
 
1652
+ project_trigger = ask_with_default(
1653
+ "Label to trigger project planning",
1654
+ existing[:project_trigger] || "aidp-project"
1655
+ )
1656
+
1652
1657
  needs_input = ask_with_default(
1653
1658
  "Label for plans needing user input",
1654
1659
  existing[:needs_input] || "aidp-needs-input"
@@ -1664,6 +1669,11 @@ module Aidp
1664
1669
  existing[:build_trigger] || "aidp-build"
1665
1670
  )
1666
1671
 
1672
+ blocked_trigger = ask_with_default(
1673
+ "Label for blocked project sub-issues",
1674
+ existing[:blocked_trigger] || "aidp-blocked"
1675
+ )
1676
+
1667
1677
  review_trigger = ask_with_default(
1668
1678
  "Label to trigger code review",
1669
1679
  existing[:review_trigger] || "aidp-review"
@@ -1686,9 +1696,11 @@ module Aidp
1686
1696
 
1687
1697
  set(%i[watch labels], {
1688
1698
  plan_trigger: plan_trigger,
1699
+ project_trigger: project_trigger,
1689
1700
  needs_input: needs_input,
1690
1701
  ready_to_build: ready_to_build,
1691
1702
  build_trigger: build_trigger,
1703
+ blocked_trigger: blocked_trigger,
1692
1704
  review_trigger: review_trigger,
1693
1705
  ci_fix_trigger: ci_fix_trigger,
1694
1706
  auto_trigger: auto_trigger,
@@ -1856,9 +1868,11 @@ module Aidp
1856
1868
  def collect_required_labels(labels_config)
1857
1869
  default_colors = {
1858
1870
  plan_trigger: "0E8A16", # Green
1871
+ project_trigger: "0052CC", # Deep blue
1859
1872
  needs_input: "D93F0B", # Red
1860
1873
  ready_to_build: "0075CA", # Blue
1861
1874
  build_trigger: "5319E7", # Purple
1875
+ blocked_trigger: "B60205", # Dark red
1862
1876
  review_trigger: "FBCA04", # Yellow
1863
1877
  ci_fix_trigger: "D93F0B", # Red
1864
1878
  auto_trigger: "0C8BD6", # Blue (distinct from build)
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.43.0"
4
+ VERSION = "0.44.0"
5
5
  end
@@ -27,7 +27,7 @@ module Aidp
27
27
  PROMPT
28
28
 
29
29
  HIERARCHICAL_PROVIDER_PROMPT = <<~PROMPT
30
- You are AIDP's planning specialist for large projects. Read the GitHub issue and existing comments.
30
+ You are AIDP's project planning specialist for large projects. Read the GitHub issue and existing comments.
31
31
  This is a LARGE project that should be broken down into independent sub-issues.
32
32
 
33
33
  Analyze the requirements and break them into logical sub-tasks that can be worked on independently.
@@ -59,6 +59,7 @@ module Aidp
59
59
  - Sub-issues should be sized for 1-3 days of work
60
60
  - Include 3-8 sub-issues for a large project
61
61
  - Be specific about skills needed (avoid generic terms)
62
+ - Prefer leaf-node tasks first so dependency order is explicit and mechanically executable
62
63
  - Only create sub-issues if the project truly warrants it (complexity, multiple components, etc.)
63
64
  PROMPT
64
65
 
@@ -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