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 +4 -4
- data/lib/aidp/config.rb +2 -0
- data/lib/aidp/setup/wizard.rb +14 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/plan_generator.rb +2 -1
- data/lib/aidp/watch/plan_processor.rb +472 -18
- data/lib/aidp/watch/repository_client.rb +194 -0
- 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 +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d898c7dea670e05503b8777e0907e0d0f975d398fd3cf80d9ddc659e8806070c
|
|
4
|
+
data.tar.gz: c25d8e50f56ce0e89f93c3e5fea9d4fb0ca6d540c19ac5fd3d2710d38be57290
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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",
|
data/lib/aidp/setup/wizard.rb
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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,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
|