aidp 0.25.0 → 0.26.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.
@@ -41,21 +41,46 @@ module Aidp
41
41
 
42
42
  def process(issue)
43
43
  number = issue[:number]
44
- if @state_store.plan_processed?(number)
45
- display_message("ℹ️ Plan for issue ##{number} already posted. Skipping.", type: :muted)
46
- return
44
+ existing_plan = @state_store.plan_data(number)
45
+
46
+ if existing_plan
47
+ display_message("🔄 Re-planning for issue ##{number} (iteration #{@state_store.plan_iteration_count(number) + 1})", type: :info)
48
+ else
49
+ display_message("🧠 Generating plan for issue ##{number} (#{issue[:title]})", type: :info)
47
50
  end
48
51
 
49
- display_message("🧠 Generating plan for issue ##{number} (#{issue[:title]})", type: :info)
50
52
  plan_data = @plan_generator.generate(issue)
51
53
 
52
54
  # Fetch the user who added the most recent label
53
55
  label_actor = @repository_client.most_recent_label_actor(number)
54
56
 
55
- comment_body = build_comment(issue: issue, plan: plan_data, label_actor: label_actor)
56
- @repository_client.post_comment(number, comment_body)
57
+ # If updating existing plan, archive the previous content
58
+ archived_content = existing_plan ? archive_previous_plan(number, existing_plan) : nil
59
+
60
+ comment_body = build_comment(issue: issue, plan: plan_data, label_actor: label_actor, archived_content: archived_content)
61
+
62
+ if existing_plan && existing_plan["comment_id"]
63
+ # Update existing comment
64
+ @repository_client.update_comment(existing_plan["comment_id"], comment_body)
65
+ display_message("📝 Updated plan comment for issue ##{number}", type: :success)
66
+ elsif existing_plan
67
+ # Try to find existing comment by header
68
+ existing_comment = @repository_client.find_comment(number, COMMENT_HEADER)
69
+ if existing_comment
70
+ @repository_client.update_comment(existing_comment[:id], comment_body)
71
+ display_message("📝 Updated plan comment for issue ##{number}", type: :success)
72
+ plan_data = plan_data.merge(comment_id: existing_comment[:id])
73
+ else
74
+ # Fallback to posting new comment if we can't find the old one
75
+ @repository_client.post_comment(number, comment_body)
76
+ display_message("💬 Posted new plan comment for issue ##{number}", type: :success)
77
+ end
78
+ else
79
+ # First time planning - post new comment
80
+ @repository_client.post_comment(number, comment_body)
81
+ display_message("💬 Posted plan comment for issue ##{number}", type: :success)
82
+ end
57
83
 
58
- display_message("💬 Posted plan comment for issue ##{number}", type: :success)
59
84
  @state_store.record_plan(number, plan_data.merge(comment_body: comment_body, comment_hint: COMMENT_HEADER))
60
85
 
61
86
  # Update labels: remove plan trigger, add appropriate status label
@@ -64,6 +89,31 @@ module Aidp
64
89
 
65
90
  private
66
91
 
92
+ def archive_previous_plan(number, existing_plan)
93
+ iteration = @state_store.plan_iteration_count(number)
94
+ timestamp = existing_plan["posted_at"] || "unknown"
95
+
96
+ archived_parts = []
97
+ archived_parts << "<!-- ARCHIVED_PLAN_START iteration=#{iteration} timestamp=#{timestamp} -->"
98
+ archived_parts << "<details>"
99
+ archived_parts << "<summary>📋 Previous Plan (Iteration #{iteration}) - #{timestamp}</summary>"
100
+ archived_parts << ""
101
+ archived_parts << "<!-- ARCHIVED_PLAN_SUMMARY_START -->"
102
+ archived_parts << "### Plan Summary"
103
+ archived_parts << existing_plan["summary"].to_s
104
+ archived_parts << "<!-- ARCHIVED_PLAN_SUMMARY_END -->"
105
+ archived_parts << ""
106
+ archived_parts << "<!-- ARCHIVED_PLAN_TASKS_START -->"
107
+ archived_parts << "### Proposed Tasks"
108
+ archived_parts << format_bullets(Array(existing_plan["tasks"]), placeholder: "_No tasks_")
109
+ archived_parts << "<!-- ARCHIVED_PLAN_TASKS_END -->"
110
+ archived_parts << ""
111
+ archived_parts << "</details>"
112
+ archived_parts << "<!-- ARCHIVED_PLAN_END -->"
113
+
114
+ archived_parts.join("\n")
115
+ end
116
+
67
117
  def update_labels_after_plan(number, plan_data)
68
118
  questions = Array(plan_data[:questions])
69
119
  has_questions = questions.any? && !questions.all? { |q| q.to_s.strip.empty? }
@@ -85,7 +135,7 @@ module Aidp
85
135
  end
86
136
  end
87
137
 
88
- def build_comment(issue:, plan:, label_actor: nil)
138
+ def build_comment(issue:, plan:, label_actor: nil, archived_content: nil)
89
139
  summary = plan[:summary].to_s.strip
90
140
  tasks = Array(plan[:tasks])
91
141
  questions = Array(plan[:questions])
@@ -104,14 +154,27 @@ module Aidp
104
154
  parts << "**Issue**: [##{issue[:number]}](#{issue[:url]})"
105
155
  parts << "**Title**: #{issue[:title]}"
106
156
  parts << ""
157
+
158
+ # Add archived content if this is a plan update
159
+ if archived_content
160
+ parts << archived_content
161
+ parts << ""
162
+ end
163
+
164
+ parts << "<!-- PLAN_SUMMARY_START -->"
107
165
  parts << "### Plan Summary"
108
166
  parts << (summary.empty? ? "_No summary generated_" : summary)
167
+ parts << "<!-- PLAN_SUMMARY_END -->"
109
168
  parts << ""
169
+ parts << "<!-- PLAN_TASKS_START -->"
110
170
  parts << "### Proposed Tasks"
111
171
  parts << format_bullets(tasks, placeholder: "_Pending task breakdown_")
172
+ parts << "<!-- PLAN_TASKS_END -->"
112
173
  parts << ""
174
+ parts << "<!-- CLARIFYING_QUESTIONS_START -->"
113
175
  parts << "### Clarifying Questions"
114
176
  parts << format_numbered(questions, placeholder: "_No questions identified_")
177
+ parts << "<!-- CLARIFYING_QUESTIONS_END -->"
115
178
  parts << ""
116
179
 
117
180
  # Add instructions based on whether there are questions
@@ -61,6 +61,14 @@ module Aidp
61
61
  gh_available? ? post_comment_via_gh(number, body) : post_comment_via_api(number, body)
62
62
  end
63
63
 
64
+ def find_comment(number, header_text)
65
+ gh_available? ? find_comment_via_gh(number, header_text) : find_comment_via_api(number, header_text)
66
+ end
67
+
68
+ def update_comment(comment_id, body)
69
+ gh_available? ? update_comment_via_gh(comment_id, body) : update_comment_via_api(comment_id, body)
70
+ end
71
+
64
72
  def create_pull_request(title:, body:, head:, base:, issue_number:, draft: false, assignee: nil)
65
73
  gh_available? ? create_pull_request_via_gh(title: title, body: body, head: head, base: base, issue_number: issue_number, draft: draft, assignee: assignee) : raise("GitHub CLI not available - cannot create PR")
66
74
  end
@@ -108,6 +116,10 @@ module Aidp
108
116
  gh_available? ? list_pull_requests_via_gh(labels: labels, state: state) : list_pull_requests_via_api(labels: labels, state: state)
109
117
  end
110
118
 
119
+ def fetch_pr_comments(number)
120
+ gh_available? ? fetch_pr_comments_via_gh(number) : fetch_pr_comments_via_api(number)
121
+ end
122
+
111
123
  private
112
124
 
113
125
  def list_issues_via_gh(labels:, state:)
@@ -162,27 +174,11 @@ module Aidp
162
174
  raise "GitHub API error (#{response.code})" unless response.code == "200"
163
175
 
164
176
  data = JSON.parse(response.body)
165
- comments = fetch_comments_via_api(number)
177
+ comments = fetch_pr_comments_via_api(number)
166
178
  data["comments"] = comments
167
179
  normalize_issue_detail_api(data)
168
180
  end
169
181
 
170
- def fetch_comments_via_api(number)
171
- uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
172
- response = Net::HTTP.get_response(uri)
173
- return [] unless response.code == "200"
174
-
175
- JSON.parse(response.body).map do |raw|
176
- {
177
- "body" => raw["body"],
178
- "author" => raw.dig("user", "login"),
179
- "createdAt" => raw["created_at"]
180
- }
181
- end
182
- rescue
183
- []
184
- end
185
-
186
182
  def post_comment_via_gh(number, body)
187
183
  cmd = ["gh", "issue", "comment", number.to_s, "--repo", full_repo, "--body", body]
188
184
  stdout, stderr, status = Open3.capture3(*cmd)
@@ -205,6 +201,44 @@ module Aidp
205
201
  response.body
206
202
  end
207
203
 
204
+ def find_comment_via_gh(number, header_text)
205
+ comments = fetch_pr_comments_via_gh(number)
206
+ comments.find { |comment| comment[:body]&.include?(header_text) }
207
+ rescue => e
208
+ Aidp.log_warn("repository_client", "Failed to find comment", error: e.message)
209
+ nil
210
+ end
211
+
212
+ def find_comment_via_api(number, header_text)
213
+ comments = fetch_pr_comments_via_api(number)
214
+ comments.find { |comment| comment[:body]&.include?(header_text) }
215
+ rescue => e
216
+ Aidp.log_warn("repository_client", "Failed to find comment", error: e.message)
217
+ nil
218
+ end
219
+
220
+ def update_comment_via_gh(comment_id, body)
221
+ cmd = ["gh", "api", "repos/#{full_repo}/issues/comments/#{comment_id}", "-X", "PATCH", "-f", "body=#{body}"]
222
+ stdout, stderr, status = Open3.capture3(*cmd)
223
+ raise "Failed to update comment via gh: #{stderr.strip}" unless status.success?
224
+
225
+ stdout.strip
226
+ end
227
+
228
+ def update_comment_via_api(comment_id, body)
229
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/comments/#{comment_id}")
230
+ request = Net::HTTP::Patch.new(uri)
231
+ request["Content-Type"] = "application/json"
232
+ request.body = JSON.dump({body: body})
233
+
234
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
235
+ http.request(request)
236
+ end
237
+
238
+ raise "GitHub API update comment failed (#{response.code})" unless response.code.start_with?("2")
239
+ response.body
240
+ end
241
+
208
242
  def create_pull_request_via_gh(title:, body:, head:, base:, issue_number:, draft: false, assignee: nil)
209
243
  cmd = [
210
244
  "gh", "pr", "create",
@@ -535,6 +569,27 @@ module Aidp
535
569
  response.body
536
570
  end
537
571
 
572
+ def fetch_pr_comments_via_gh(number)
573
+ cmd = ["gh", "api", "repos/#{full_repo}/issues/#{number}/comments", "--jq", "."]
574
+ stdout, stderr, status = Open3.capture3(*cmd)
575
+ raise "Failed to fetch PR comments via gh: #{stderr.strip}" unless status.success?
576
+
577
+ JSON.parse(stdout).map { |raw| normalize_pr_comment(raw) }
578
+ rescue JSON::ParserError => e
579
+ raise "Failed to parse PR comments response: #{e.message}"
580
+ end
581
+
582
+ def fetch_pr_comments_via_api(number)
583
+ uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
584
+ response = Net::HTTP.get_response(uri)
585
+ return [] unless response.code == "200"
586
+
587
+ JSON.parse(response.body).map { |raw| normalize_pr_comment(raw) }
588
+ rescue => e
589
+ Aidp.log_warn("repository_client", "Failed to fetch PR comments", error: e.message)
590
+ []
591
+ end
592
+
538
593
  # Normalization methods for PRs
539
594
  def normalize_pull_request(raw)
540
595
  {
@@ -691,14 +746,24 @@ module Aidp
691
746
  def normalize_comment(comment)
692
747
  if comment.is_a?(Hash)
693
748
  {
694
- "body" => comment["body"],
695
- "author" => comment["author"] || comment.dig("user", "login"),
696
- "createdAt" => comment["createdAt"] || comment["created_at"]
749
+ "body" => comment["body"] || comment[:body],
750
+ "author" => comment["author"] || comment[:author] || comment.dig("user", "login"),
751
+ "createdAt" => comment["createdAt"] || comment[:created_at] || comment["created_at"]
697
752
  }
698
753
  else
699
754
  {"body" => comment.to_s}
700
755
  end
701
756
  end
757
+
758
+ def normalize_pr_comment(raw)
759
+ {
760
+ id: raw["id"],
761
+ body: raw["body"],
762
+ author: raw.dig("user", "login"),
763
+ created_at: raw["created_at"],
764
+ updated_at: raw["updated_at"]
765
+ }
766
+ end
702
767
  end
703
768
  end
704
769
  end
@@ -12,6 +12,7 @@ require_relative "build_processor"
12
12
  require_relative "../auto_update"
13
13
  require_relative "review_processor"
14
14
  require_relative "ci_fix_processor"
15
+ require_relative "change_request_processor"
15
16
 
16
17
  module Aidp
17
18
  module Watch
@@ -77,6 +78,16 @@ module Aidp
77
78
  label_config: label_config,
78
79
  verbose: verbose
79
80
  )
81
+ @change_request_processor = ChangeRequestProcessor.new(
82
+ repository_client: @repository_client,
83
+ state_store: @state_store,
84
+ provider_name: provider_name,
85
+ project_dir: project_dir,
86
+ label_config: label_config,
87
+ change_request_config: safety_config[:pr_change_requests] || safety_config["pr_change_requests"] || {},
88
+ safety_config: safety_config[:safety] || safety_config["safety"] || {},
89
+ verbose: verbose
90
+ )
80
91
  end
81
92
 
82
93
  def start
@@ -122,6 +133,7 @@ module Aidp
122
133
  check_for_updates_if_due
123
134
  process_review_triggers
124
135
  process_ci_fix_triggers
136
+ process_change_request_triggers
125
137
  end
126
138
 
127
139
  def process_plan_triggers
@@ -230,6 +242,31 @@ module Aidp
230
242
  end
231
243
  end
232
244
 
245
+ def process_change_request_triggers
246
+ change_request_label = @change_request_processor.change_request_label
247
+ prs = @repository_client.list_pull_requests(labels: [change_request_label], state: "open")
248
+ Aidp.log_debug("watch_runner", "change_request_poll", label: change_request_label, total: prs.size)
249
+ prs.each do |pr|
250
+ unless pr_has_label?(pr, change_request_label)
251
+ Aidp.log_debug("watch_runner", "change_request_skip_label_mismatch", pr: pr[:number], labels: pr[:labels])
252
+ next
253
+ end
254
+
255
+ detailed = @repository_client.fetch_pull_request(pr[:number])
256
+
257
+ # Check author authorization before processing
258
+ unless @safety_checker.should_process_issue?(detailed, enforce: false)
259
+ Aidp.log_debug("watch_runner", "change_request_skip_unauthorized_author", pr: detailed[:number], author: detailed[:author])
260
+ next
261
+ end
262
+
263
+ Aidp.log_debug("watch_runner", "change_request_process", pr: detailed[:number])
264
+ @change_request_processor.process(detailed)
265
+ rescue RepositorySafetyChecker::UnauthorizedAuthorError => e
266
+ Aidp.log_warn("watch_runner", "unauthorized PR author", pr: pr[:number], error: e.message)
267
+ end
268
+ end
269
+
233
270
  def issue_has_label?(issue, label)
234
271
  Array(issue[:labels]).any? do |issue_label|
235
272
  name = issue_label.is_a?(Hash) ? issue_label["name"] : issue_label.to_s
@@ -27,14 +27,26 @@ module Aidp
27
27
  plans[issue_number.to_s]
28
28
  end
29
29
 
30
+ def plan_iteration_count(issue_number)
31
+ plan = plans[issue_number.to_s]
32
+ return 0 unless plan
33
+ plan["iteration"] || 1
34
+ end
35
+
30
36
  def record_plan(issue_number, data)
37
+ existing_plan = plans[issue_number.to_s]
38
+ iteration = existing_plan ? (existing_plan["iteration"] || 1) + 1 : 1
39
+
31
40
  payload = {
32
41
  "summary" => data[:summary],
33
42
  "tasks" => data[:tasks],
34
43
  "questions" => data[:questions],
35
44
  "comment_body" => data[:comment_body],
36
45
  "comment_hint" => data[:comment_hint],
37
- "posted_at" => data[:posted_at] || Time.now.utc.iso8601
46
+ "comment_id" => data[:comment_id],
47
+ "posted_at" => data[:posted_at] || Time.now.utc.iso8601,
48
+ "iteration" => iteration,
49
+ "previous_iteration_at" => existing_plan ? existing_plan["posted_at"] : nil
38
50
  }.compact
39
51
 
40
52
  plans[issue_number.to_s] = payload
@@ -96,6 +108,34 @@ module Aidp
96
108
  save!
97
109
  end
98
110
 
111
+ # Change request tracking methods
112
+ def change_request_processed?(pr_number)
113
+ change_requests.key?(pr_number.to_s)
114
+ end
115
+
116
+ def change_request_data(pr_number)
117
+ change_requests[pr_number.to_s]
118
+ end
119
+
120
+ def record_change_request(pr_number, data)
121
+ payload = {
122
+ "status" => data[:status],
123
+ "timestamp" => data[:timestamp] || Time.now.utc.iso8601,
124
+ "changes_applied" => data[:changes_applied],
125
+ "commits" => data[:commits],
126
+ "reason" => data[:reason],
127
+ "clarification_count" => data[:clarification_count]
128
+ }.compact
129
+
130
+ change_requests[pr_number.to_s] = payload
131
+ save!
132
+ end
133
+
134
+ def reset_change_request_state(pr_number)
135
+ change_requests.delete(pr_number.to_s)
136
+ save!
137
+ end
138
+
99
139
  private
100
140
 
101
141
  def ensure_directory
@@ -126,6 +166,7 @@ module Aidp
126
166
  base["builds"] ||= {}
127
167
  base["reviews"] ||= {}
128
168
  base["ci_fixes"] ||= {}
169
+ base["change_requests"] ||= {}
129
170
  base
130
171
  end
131
172
  end
@@ -146,6 +187,10 @@ module Aidp
146
187
  state["ci_fixes"]
147
188
  end
148
189
 
190
+ def change_requests
191
+ state["change_requests"]
192
+ end
193
+
149
194
  def stringify_keys(hash)
150
195
  return {} unless hash
151
196
 
@@ -156,8 +156,11 @@ module Aidp
156
156
  )
157
157
 
158
158
  # Log error and exit
159
- warn("Error in workstream #{slug}: #{e.message}")
160
- warn(e.backtrace.first(5).join("\n"))
159
+ # Suppress backtrace noise during tests while keeping it for production debugging
160
+ unless ENV["RSPEC_RUNNING"] == "true"
161
+ warn("Error in workstream #{slug}: #{e.message}")
162
+ warn(e.backtrace.first(5).join("\n"))
163
+ end
161
164
  exit(1)
162
165
  end
163
166
 
data/lib/aidp.rb CHANGED
@@ -93,6 +93,10 @@ require_relative "aidp/harness/state_manager"
93
93
  require_relative "aidp/harness/error_handler"
94
94
  require_relative "aidp/harness/status_display"
95
95
  require_relative "aidp/harness/runner"
96
+ require_relative "aidp/harness/filter_strategy"
97
+ require_relative "aidp/harness/generic_filter_strategy"
98
+ require_relative "aidp/harness/rspec_filter_strategy"
99
+ require_relative "aidp/harness/output_filter"
96
100
 
97
101
  # UI components
98
102
  require_relative "aidp/harness/ui/spinner_helper"
@@ -708,6 +708,56 @@ devcontainer:
708
708
  # - "api.example.com"
709
709
  # - "registry.example.com"
710
710
 
711
+ # Watch mode configuration
712
+ # Configures automated monitoring and processing of GitHub issues/PRs
713
+ watch:
714
+ # Label configuration for different triggers
715
+ labels:
716
+ plan_trigger: "aidp-plan" # Trigger plan generation
717
+ needs_input: "aidp-needs-input" # Request clarification
718
+ ready_to_build: "aidp-ready" # Ready for implementation
719
+ build_trigger: "aidp-build" # Trigger implementation
720
+ review_trigger: "aidp-review" # Trigger code review
721
+ ci_fix_trigger: "aidp-fix-ci" # Trigger CI fix
722
+ change_request_trigger: "aidp-request-changes" # Trigger PR change requests
723
+
724
+ # Safety configuration
725
+ safety:
726
+ # Allow watch mode on public repositories (default: false for safety)
727
+ allow_public_repos: false
728
+
729
+ # List of GitHub usernames allowed to trigger automated actions
730
+ # Empty list means all authenticated users (for private repos)
731
+ author_allowlist: []
732
+ # - "user1"
733
+ # - "user2"
734
+
735
+ # Require running in a container environment (additional safety)
736
+ require_container: false
737
+
738
+ # PR change request configuration
739
+ pr_change_requests:
740
+ # Enable/disable PR change request feature
741
+ enabled: true
742
+
743
+ # Allow changes across multiple files in a single request
744
+ allow_multi_file_edits: true
745
+
746
+ # Run tests and linters before pushing changes
747
+ # If tests fail, changes are committed locally but not pushed
748
+ run_tests_before_push: true
749
+
750
+ # Prefix for commit messages created by change request processor
751
+ commit_message_prefix: "aidp: pr-change"
752
+
753
+ # Require at least one comment before processing change request
754
+ # This ensures there's context for what changes are requested
755
+ require_comment_reference: true
756
+
757
+ # Maximum PR diff size (in lines) to process
758
+ # Large PRs are skipped to avoid overwhelming the AI analysis
759
+ max_diff_size: 2000
760
+
711
761
  # Configuration tips:
712
762
  # - Set max_tokens based on your API plan limits
713
763
  # - Use default_flags to customize provider behavior
@@ -724,3 +774,6 @@ devcontainer:
724
774
  # - Use time-based configs for different usage patterns
725
775
  # - Use step-specific configs for different workflow steps
726
776
  # - Use user-specific configs for personalized experiences
777
+ # - Configure watch mode safety settings before using on public repositories
778
+ # - Set author_allowlist to restrict who can trigger automated actions
779
+ # - Adjust max_diff_size based on your typical PR sizes and AI capabilities
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aidp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bart Agapinan
@@ -305,12 +305,16 @@ files:
305
305
  - lib/aidp/harness/configuration.rb
306
306
  - lib/aidp/harness/enhanced_runner.rb
307
307
  - lib/aidp/harness/error_handler.rb
308
+ - lib/aidp/harness/filter_strategy.rb
309
+ - lib/aidp/harness/generic_filter_strategy.rb
310
+ - lib/aidp/harness/output_filter.rb
308
311
  - lib/aidp/harness/provider_config.rb
309
312
  - lib/aidp/harness/provider_factory.rb
310
313
  - lib/aidp/harness/provider_info.rb
311
314
  - lib/aidp/harness/provider_manager.rb
312
315
  - lib/aidp/harness/provider_metrics.rb
313
316
  - lib/aidp/harness/provider_type_checker.rb
317
+ - lib/aidp/harness/rspec_filter_strategy.rb
314
318
  - lib/aidp/harness/runner.rb
315
319
  - lib/aidp/harness/simple_user_interface.rb
316
320
  - lib/aidp/harness/state/errors.rb
@@ -400,6 +404,7 @@ files:
400
404
  - lib/aidp/version.rb
401
405
  - lib/aidp/watch.rb
402
406
  - lib/aidp/watch/build_processor.rb
407
+ - lib/aidp/watch/change_request_processor.rb
403
408
  - lib/aidp/watch/ci_fix_processor.rb
404
409
  - lib/aidp/watch/plan_generator.rb
405
410
  - lib/aidp/watch/plan_processor.rb