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.
- checksums.yaml +4 -4
- data/lib/aidp/config.rb +15 -1
- data/lib/aidp/setup/wizard.rb +14 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/gantt_synchronizer.rb +273 -0
- data/lib/aidp/watch/plan_generator.rb +2 -1
- data/lib/aidp/watch/plan_processor.rb +494 -18
- data/lib/aidp/watch/prd_parser.rb +395 -0
- data/lib/aidp/watch/projects_processor.rb +88 -11
- data/lib/aidp/watch/repository_client.rb +212 -1
- data/lib/aidp/watch/runner.rb +117 -9
- data/lib/aidp/watch/state_store.rb +41 -1
- data/lib/aidp/watch/sub_issue_creator.rb +133 -19
- metadata +3 -1
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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:
|
|
167
|
+
old_labels: old_labels,
|
|
137
168
|
new_labels: [new_label]
|
|
138
169
|
)
|
|
139
|
-
display_message("๐ท๏ธ Updated labels: removed '#{
|
|
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
|
-
|
|
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
|