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.
@@ -0,0 +1,659 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+ require "json"
6
+ require "time"
7
+
8
+ require_relative "../message_display"
9
+ require_relative "../provider_manager"
10
+ require_relative "../harness/config_manager"
11
+ require_relative "../execute/prompt_manager"
12
+ require_relative "../harness/runner"
13
+ require_relative "../harness/state_manager"
14
+ require_relative "../harness/test_runner"
15
+
16
+ module Aidp
17
+ module Watch
18
+ # Handles the aidp-request-changes label trigger by analyzing PR comments
19
+ # and automatically implementing the requested changes.
20
+ class ChangeRequestProcessor
21
+ include Aidp::MessageDisplay
22
+
23
+ # Default label names
24
+ DEFAULT_CHANGE_REQUEST_LABEL = "aidp-request-changes"
25
+ DEFAULT_NEEDS_INPUT_LABEL = "aidp-needs-input"
26
+
27
+ COMMENT_HEADER = "## 🤖 AIDP Change Request"
28
+ MAX_CLARIFICATION_ROUNDS = 3
29
+
30
+ attr_reader :change_request_label, :needs_input_label
31
+
32
+ def initialize(repository_client:, state_store:, provider_name: nil, project_dir: Dir.pwd, label_config: {}, change_request_config: {}, safety_config: {}, verbose: false)
33
+ @repository_client = repository_client
34
+ @state_store = state_store
35
+ @provider_name = provider_name
36
+ @project_dir = project_dir
37
+ @verbose = verbose
38
+
39
+ # Load label configuration
40
+ @change_request_label = label_config[:change_request_trigger] || label_config["change_request_trigger"] || DEFAULT_CHANGE_REQUEST_LABEL
41
+ @needs_input_label = label_config[:needs_input] || label_config["needs_input"] || DEFAULT_NEEDS_INPUT_LABEL
42
+
43
+ # Load change request configuration
44
+ @config = {
45
+ enabled: true,
46
+ allow_multi_file_edits: true,
47
+ run_tests_before_push: true,
48
+ commit_message_prefix: "aidp: pr-change",
49
+ require_comment_reference: true,
50
+ max_diff_size: 2000
51
+ }.merge(symbolize_keys(change_request_config))
52
+
53
+ # Load safety configuration
54
+ @safety_config = safety_config
55
+ @author_allowlist = Array(@safety_config[:author_allowlist] || @safety_config["author_allowlist"])
56
+ end
57
+
58
+ def process(pr)
59
+ number = pr[:number]
60
+
61
+ unless @config[:enabled]
62
+ display_message("â„šī¸ PR change requests are disabled in configuration. Skipping PR ##{number}.", type: :muted)
63
+ return
64
+ end
65
+
66
+ # Check clarification round limit
67
+ existing_data = @state_store.change_request_data(number)
68
+ if existing_data && existing_data["clarification_count"].to_i >= MAX_CLARIFICATION_ROUNDS
69
+ display_message("âš ī¸ Max clarification rounds (#{MAX_CLARIFICATION_ROUNDS}) reached for PR ##{number}. Skipping.", type: :warn)
70
+ post_max_rounds_comment(pr)
71
+ return
72
+ end
73
+
74
+ display_message("📝 Processing change request for PR ##{number} (#{pr[:title]})", type: :info)
75
+
76
+ # Fetch PR details
77
+ pr_data = @repository_client.fetch_pull_request(number)
78
+ comments = @repository_client.fetch_pr_comments(number)
79
+
80
+ # Filter comments from authorized users
81
+ authorized_comments = filter_authorized_comments(comments, pr_data)
82
+
83
+ if authorized_comments.empty?
84
+ display_message("â„šī¸ No authorized comments found for PR ##{number}. Skipping.", type: :muted)
85
+ return
86
+ end
87
+
88
+ # Fetch diff to check size
89
+ diff = @repository_client.fetch_pull_request_diff(number)
90
+ diff_size = diff.lines.count
91
+
92
+ if diff_size > @config[:max_diff_size]
93
+ display_message("âš ī¸ PR ##{number} diff too large (#{diff_size} lines > #{@config[:max_diff_size]}). Skipping.", type: :warn)
94
+ post_diff_too_large_comment(pr, diff_size)
95
+ return
96
+ end
97
+
98
+ # Analyze change requests
99
+ analysis_result = analyze_change_requests(pr_data: pr_data, comments: authorized_comments, diff: diff)
100
+
101
+ if analysis_result[:needs_clarification]
102
+ handle_clarification_needed(pr: pr_data, analysis: analysis_result)
103
+ elsif analysis_result[:can_implement]
104
+ implement_changes(pr: pr_data, analysis: analysis_result, diff: diff)
105
+ else
106
+ handle_cannot_implement(pr: pr_data, analysis: analysis_result)
107
+ end
108
+ rescue => e
109
+ display_message("❌ Change request processing failed: #{e.message}", type: :error)
110
+ Aidp.log_error("change_request_processor", "Change request failed", pr: pr[:number], error: e.message, backtrace: e.backtrace&.first(10))
111
+
112
+ # Post error comment
113
+ error_comment = <<~COMMENT
114
+ #{COMMENT_HEADER}
115
+
116
+ ❌ Automated change request processing failed: #{e.message}
117
+
118
+ Please review the requested changes manually or retry by re-adding the `#{@change_request_label}` label.
119
+ COMMENT
120
+ begin
121
+ @repository_client.post_comment(pr[:number], error_comment)
122
+ rescue
123
+ nil
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def filter_authorized_comments(comments, pr_data)
130
+ # If allowlist is empty (for private repos), consider PR author and all commenters
131
+ # For public repos, enforce allowlist
132
+ if @author_allowlist.empty?
133
+ # Private repo: trust all comments from PR participants
134
+ comments
135
+ else
136
+ # Public repo: only allow comments from allowlisted users
137
+ comments.select do |comment|
138
+ author = comment[:author]
139
+ @author_allowlist.include?(author)
140
+ end
141
+ end
142
+ end
143
+
144
+ def analyze_change_requests(pr_data:, comments:, diff:)
145
+ provider_name = @provider_name || detect_default_provider
146
+ provider = Aidp::ProviderManager.get_provider(provider_name, use_harness: false)
147
+
148
+ user_prompt = build_analysis_prompt(pr_data: pr_data, comments: comments, diff: diff)
149
+ full_prompt = "#{change_request_system_prompt}\n\n#{user_prompt}"
150
+
151
+ Aidp.log_debug("change_request_processor", "Analyzing change requests", pr: pr_data[:number], comments_count: comments.length)
152
+
153
+ response = provider.send_message(prompt: full_prompt)
154
+ content = response.to_s.strip
155
+
156
+ # Extract JSON from response
157
+ json_content = extract_json(content)
158
+
159
+ # Parse JSON response
160
+ parsed = JSON.parse(json_content)
161
+
162
+ {
163
+ can_implement: parsed["can_implement"],
164
+ needs_clarification: parsed["needs_clarification"],
165
+ clarifying_questions: parsed["clarifying_questions"] || [],
166
+ reason: parsed["reason"],
167
+ changes: parsed["changes"] || []
168
+ }
169
+ rescue JSON::ParserError => e
170
+ Aidp.log_error("change_request_processor", "Failed to parse AI response", error: e.message, content: content)
171
+ {can_implement: false, needs_clarification: false, reason: "Failed to parse AI analysis"}
172
+ rescue => e
173
+ Aidp.log_error("change_request_processor", "AI analysis failed", error: e.message)
174
+ {can_implement: false, needs_clarification: false, reason: "AI analysis error: #{e.message}"}
175
+ end
176
+
177
+ def change_request_system_prompt
178
+ <<~PROMPT
179
+ You are an expert software engineer analyzing change requests from PR comments.
180
+
181
+ Your task is to:
182
+ 1. Read all comments and understand what changes are being requested
183
+ 2. Weight newer comments higher than older ones
184
+ 3. If multiple approved commenters request different things, consider the most recent request
185
+ 4. Determine if you can confidently implement the requested changes
186
+
187
+ Respond in JSON format:
188
+ {
189
+ "can_implement": true/false,
190
+ "needs_clarification": true/false,
191
+ "clarifying_questions": ["Question 1?", "Question 2?"],
192
+ "reason": "Brief explanation of your decision",
193
+ "changes": [
194
+ {
195
+ "file": "path/to/file",
196
+ "action": "edit|create|delete",
197
+ "content": "Full file content after change (for create/edit)",
198
+ "description": "What this change does",
199
+ "line_start": 10,
200
+ "line_end": 20
201
+ }
202
+ ]
203
+ }
204
+
205
+ Set "can_implement" to true ONLY if:
206
+ - The requested changes are clear and unambiguous
207
+ - You understand the codebase context from the PR diff
208
+ - The changes are technically feasible
209
+ - You can provide complete, correct implementations
210
+
211
+ Set "needs_clarification" to true if:
212
+ - Multiple conflicting requests exist
213
+ - The request is vague or incomplete
214
+ - You need more context to implement correctly
215
+ - There are unclear technical requirements
216
+
217
+ For "changes", provide the complete file content after applying the requested modifications.
218
+ Support multi-file edits by including multiple change objects.
219
+
220
+ DO NOT attempt to implement if:
221
+ - The request requires domain knowledge you don't have
222
+ - The changes could introduce security vulnerabilities
223
+ - The request is too complex for automated implementation
224
+ - You're not confident the changes are correct
225
+ PROMPT
226
+ end
227
+
228
+ def build_analysis_prompt(pr_data:, comments:, diff:)
229
+ # Sort comments by creation time, newest first
230
+ sorted_comments = comments.sort_by { |c| c[:created_at] }.reverse
231
+
232
+ comments_text = sorted_comments.map do |comment|
233
+ "**#{comment[:author]}** (#{comment[:created_at]}):\n#{comment[:body]}"
234
+ end.join("\n\n---\n\n")
235
+
236
+ <<~PROMPT
237
+ Analyze these change requests for PR ##{pr_data[:number]}: #{pr_data[:title]}
238
+
239
+ **PR Description:**
240
+ #{pr_data[:body]}
241
+
242
+ **Current PR Diff:**
243
+ ```diff
244
+ #{diff}
245
+ ```
246
+
247
+ **Comments (newest first):**
248
+ #{comments_text}
249
+
250
+ Please analyze what changes are being requested and determine if you can implement them.
251
+ PROMPT
252
+ end
253
+
254
+ def implement_changes(pr:, analysis:, diff:)
255
+ display_message("🔨 Implementing requested changes for PR ##{pr[:number]}", type: :info)
256
+
257
+ # Checkout PR branch
258
+ checkout_pr_branch(pr)
259
+
260
+ # Apply changes
261
+ apply_changes(analysis[:changes])
262
+
263
+ # Run tests if configured
264
+ if @config[:run_tests_before_push]
265
+ test_result = run_tests_and_linters
266
+ unless test_result[:success]
267
+ handle_test_failure(pr: pr, analysis: analysis, test_result: test_result)
268
+ return
269
+ end
270
+ end
271
+
272
+ # Commit and push
273
+ if commit_and_push(pr, analysis)
274
+ handle_success(pr: pr, analysis: analysis)
275
+ else
276
+ handle_no_changes(pr: pr, analysis: analysis)
277
+ end
278
+ end
279
+
280
+ def checkout_pr_branch(pr_data)
281
+ head_ref = pr_data[:head_ref]
282
+
283
+ Dir.chdir(@project_dir) do
284
+ # Fetch latest
285
+ run_git(%w[fetch origin])
286
+
287
+ # Checkout the PR branch
288
+ run_git(["checkout", head_ref])
289
+
290
+ # Pull latest changes
291
+ run_git(%w[pull --ff-only], allow_failure: true)
292
+ end
293
+
294
+ Aidp.log_debug("change_request_processor", "Checked out PR branch", branch: head_ref)
295
+ display_message("đŸŒŋ Checked out branch: #{head_ref}", type: :info)
296
+ end
297
+
298
+ def apply_changes(changes)
299
+ changes.each do |change|
300
+ file_path = File.join(@project_dir, change["file"])
301
+
302
+ case change["action"]
303
+ when "create", "edit"
304
+ FileUtils.mkdir_p(File.dirname(file_path))
305
+ File.write(file_path, change["content"])
306
+ display_message(" ✓ #{change["action"]} #{change["file"]}", type: :muted) if @verbose
307
+ Aidp.log_debug("change_request_processor", "Applied change", action: change["action"], file: change["file"])
308
+ when "delete"
309
+ File.delete(file_path) if File.exist?(file_path)
310
+ display_message(" ✓ Deleted #{change["file"]}", type: :muted) if @verbose
311
+ Aidp.log_debug("change_request_processor", "Deleted file", file: change["file"])
312
+ else
313
+ display_message(" âš ī¸ Unknown action: #{change["action"]} for #{change["file"]}", type: :warn)
314
+ Aidp.log_warn("change_request_processor", "Unknown change action", action: change["action"], file: change["file"])
315
+ end
316
+ end
317
+ end
318
+
319
+ def run_tests_and_linters
320
+ display_message("đŸ§Ē Running tests and linters...", type: :info)
321
+
322
+ config_manager = Aidp::Harness::ConfigManager.new(@project_dir)
323
+ config = config_manager.config || {}
324
+
325
+ test_runner = Aidp::Harness::TestRunner.new(@project_dir, config)
326
+
327
+ # Run linters first
328
+ lint_result = test_runner.run_linters
329
+ if lint_result && !lint_result[:passed]
330
+ return {success: false, stage: "lint", output: lint_result[:output]}
331
+ end
332
+
333
+ # Run tests
334
+ test_result = test_runner.run_tests
335
+ if test_result && !test_result[:passed]
336
+ return {success: false, stage: "test", output: test_result[:output]}
337
+ end
338
+
339
+ {success: true}
340
+ rescue => e
341
+ Aidp.log_error("change_request_processor", "Test/lint execution failed", error: e.message)
342
+ {success: false, stage: "unknown", error: e.message}
343
+ end
344
+
345
+ def commit_and_push(pr_data, analysis)
346
+ Dir.chdir(@project_dir) do
347
+ # Check if there are changes
348
+ status_output = run_git(%w[status --porcelain])
349
+ if status_output.strip.empty?
350
+ display_message("â„šī¸ No changes to commit after applying changes.", type: :muted)
351
+ return false
352
+ end
353
+
354
+ # Stage all changes
355
+ run_git(%w[add -A])
356
+
357
+ # Create commit
358
+ commit_message = build_commit_message(pr_data, analysis)
359
+ run_git(["commit", "-m", commit_message])
360
+
361
+ display_message("💾 Created commit: #{commit_message.lines.first.strip}", type: :info)
362
+ Aidp.log_debug("change_request_processor", "Created commit", pr: pr_data[:number])
363
+
364
+ # Push to origin
365
+ head_ref = pr_data[:head_ref]
366
+ run_git(["push", "origin", head_ref])
367
+
368
+ display_message("âŦ†ī¸ Pushed changes to #{head_ref}", type: :success)
369
+ Aidp.log_info("change_request_processor", "Pushed changes", pr: pr_data[:number], branch: head_ref)
370
+ true
371
+ end
372
+ end
373
+
374
+ def build_commit_message(pr_data, analysis)
375
+ prefix = @config[:commit_message_prefix]
376
+ changes_summary = analysis[:changes]&.map { |c| c["description"] }&.join(", ")
377
+ changes_summary = "requested changes" if changes_summary.nil? || changes_summary.empty?
378
+
379
+ message = "#{prefix}: #{changes_summary}\n\n"
380
+ message += "Implements change request from PR ##{pr_data[:number]} review comments.\n"
381
+ message += "\nReason: #{analysis[:reason]}\n" if analysis[:reason]
382
+ message += "\nCo-authored-by: AIDP Change Request Processor <ai@aidp.dev>"
383
+
384
+ message
385
+ end
386
+
387
+ def handle_success(pr:, analysis:)
388
+ changes_list = analysis[:changes].map do |c|
389
+ "- **#{c["file"]}**: #{c["description"]}"
390
+ end.join("\n")
391
+
392
+ comment = <<~COMMENT
393
+ #{COMMENT_HEADER}
394
+
395
+ ✅ Successfully implemented requested changes!
396
+
397
+ **Changes Applied:**
398
+ #{changes_list}
399
+
400
+ The changes have been committed and pushed to this PR.
401
+ COMMENT
402
+
403
+ @repository_client.post_comment(pr[:number], comment)
404
+ @state_store.record_change_request(pr[:number], {
405
+ status: "completed",
406
+ timestamp: Time.now.utc.iso8601,
407
+ changes_applied: analysis[:changes].length,
408
+ commits: 1
409
+ })
410
+
411
+ display_message("🎉 Posted success comment for PR ##{pr[:number]}", type: :success)
412
+ Aidp.log_info("change_request_processor", "Change request completed", pr: pr[:number], changes: analysis[:changes].length)
413
+
414
+ # Remove label after successful implementation
415
+ begin
416
+ @repository_client.remove_labels(pr[:number], @change_request_label)
417
+ display_message("đŸˇī¸ Removed '#{@change_request_label}' label after successful implementation", type: :info)
418
+ rescue => e
419
+ display_message("âš ī¸ Failed to remove change request label: #{e.message}", type: :warn)
420
+ end
421
+ end
422
+
423
+ def handle_no_changes(pr:, analysis:)
424
+ comment = <<~COMMENT
425
+ #{COMMENT_HEADER}
426
+
427
+ â„šī¸ Analysis completed but no changes were needed.
428
+
429
+ **Reason:** #{analysis[:reason] || "The requested changes may already be applied or no modifications were necessary."}
430
+
431
+ If you believe changes should be made, please clarify the request and re-add the `#{@change_request_label}` label.
432
+ COMMENT
433
+
434
+ @repository_client.post_comment(pr[:number], comment)
435
+ @state_store.record_change_request(pr[:number], {
436
+ status: "no_changes",
437
+ timestamp: Time.now.utc.iso8601,
438
+ reason: analysis[:reason]
439
+ })
440
+
441
+ display_message("â„šī¸ Posted no-changes comment for PR ##{pr[:number]}", type: :info)
442
+
443
+ # Remove label
444
+ begin
445
+ @repository_client.remove_labels(pr[:number], @change_request_label)
446
+ rescue
447
+ nil
448
+ end
449
+ end
450
+
451
+ def handle_clarification_needed(pr:, analysis:)
452
+ existing_data = @state_store.change_request_data(pr[:number])
453
+ clarification_count = (existing_data&.dig("clarification_count") || 0) + 1
454
+
455
+ questions_list = analysis[:clarifying_questions].map.with_index(1) do |q, i|
456
+ "#{i}. #{q}"
457
+ end.join("\n")
458
+
459
+ comment = <<~COMMENT
460
+ #{COMMENT_HEADER}
461
+
462
+ 🤔 I need clarification to implement the requested changes.
463
+
464
+ **Questions:**
465
+ #{questions_list}
466
+
467
+ **Reason:** #{analysis[:reason]}
468
+
469
+ Please respond to these questions in a comment, then re-apply the `#{@change_request_label}` label.
470
+
471
+ _(Clarification round #{clarification_count} of #{MAX_CLARIFICATION_ROUNDS})_
472
+ COMMENT
473
+
474
+ @repository_client.post_comment(pr[:number], comment)
475
+ @state_store.record_change_request(pr[:number], {
476
+ status: "needs_clarification",
477
+ timestamp: Time.now.utc.iso8601,
478
+ clarification_count: clarification_count,
479
+ reason: analysis[:reason]
480
+ })
481
+
482
+ display_message("🤔 Posted clarification request for PR ##{pr[:number]}", type: :info)
483
+ Aidp.log_info("change_request_processor", "Clarification needed", pr: pr[:number], round: clarification_count)
484
+
485
+ # Replace label with needs-input label
486
+ begin
487
+ @repository_client.replace_labels(pr[:number], old_labels: [@change_request_label], new_labels: [@needs_input_label])
488
+ display_message("đŸˇī¸ Replaced '#{@change_request_label}' with '#{@needs_input_label}' label", type: :info)
489
+ rescue => e
490
+ display_message("âš ī¸ Failed to update labels: #{e.message}", type: :warn)
491
+ end
492
+ end
493
+
494
+ def handle_cannot_implement(pr:, analysis:)
495
+ comment = <<~COMMENT
496
+ #{COMMENT_HEADER}
497
+
498
+ âš ī¸ Cannot automatically implement the requested changes.
499
+
500
+ **Reason:** #{analysis[:reason] || "The request is too complex or unclear for automated implementation."}
501
+
502
+ Please consider:
503
+ 1. Breaking down the request into smaller, more specific changes
504
+ 2. Providing additional context or examples
505
+ 3. Implementing the changes manually
506
+
507
+ You can retry by re-adding the `#{@change_request_label}` label with clarified instructions.
508
+ COMMENT
509
+
510
+ @repository_client.post_comment(pr[:number], comment)
511
+ @state_store.record_change_request(pr[:number], {
512
+ status: "cannot_implement",
513
+ timestamp: Time.now.utc.iso8601,
514
+ reason: analysis[:reason]
515
+ })
516
+
517
+ display_message("âš ī¸ Posted cannot-implement comment for PR ##{pr[:number]}", type: :warn)
518
+ Aidp.log_info("change_request_processor", "Cannot implement", pr: pr[:number], reason: analysis[:reason])
519
+
520
+ # Remove label
521
+ begin
522
+ @repository_client.remove_labels(pr[:number], @change_request_label)
523
+ rescue
524
+ nil
525
+ end
526
+ end
527
+
528
+ def handle_test_failure(pr:, analysis:, test_result:)
529
+ stage = test_result[:stage]
530
+ output = test_result[:output] || test_result[:error] || "Unknown error"
531
+
532
+ comment = <<~COMMENT
533
+ #{COMMENT_HEADER}
534
+
535
+ ❌ Changes were applied but #{stage} failed.
536
+
537
+ **#{stage.capitalize} Output:**
538
+ ```
539
+ #{output.lines.first(50).join}
540
+ ```
541
+
542
+ Using fix-forward strategy: the changes have been committed but not pushed.
543
+ Please review the #{stage} failures and either:
544
+ 1. Fix the issues manually
545
+ 2. Provide additional context in a comment and re-add the `#{@change_request_label}` label
546
+ COMMENT
547
+
548
+ @repository_client.post_comment(pr[:number], comment)
549
+ @state_store.record_change_request(pr[:number], {
550
+ status: "test_failed",
551
+ timestamp: Time.now.utc.iso8601,
552
+ reason: "#{stage} failed after applying changes",
553
+ changes_applied: analysis[:changes].length
554
+ })
555
+
556
+ display_message("❌ Posted test failure comment for PR ##{pr[:number]}", type: :error)
557
+ Aidp.log_error("change_request_processor", "Test/lint failure", pr: pr[:number], stage: stage)
558
+
559
+ # Remove label
560
+ begin
561
+ @repository_client.remove_labels(pr[:number], @change_request_label)
562
+ rescue
563
+ nil
564
+ end
565
+ end
566
+
567
+ def post_max_rounds_comment(pr)
568
+ comment = <<~COMMENT
569
+ #{COMMENT_HEADER}
570
+
571
+ ⛔ Maximum clarification rounds (#{MAX_CLARIFICATION_ROUNDS}) reached.
572
+
573
+ Unable to proceed with automated implementation. Please consider:
574
+ 1. Implementing the changes manually
575
+ 2. Creating a new, more specific change request
576
+ 3. Providing all necessary context upfront
577
+
578
+ To reset and try again, remove the current state and re-add the `#{@change_request_label}` label.
579
+ COMMENT
580
+
581
+ begin
582
+ @repository_client.post_comment(pr[:number], comment)
583
+ @repository_client.remove_labels(pr[:number], @change_request_label)
584
+ rescue
585
+ nil
586
+ end
587
+ end
588
+
589
+ def post_diff_too_large_comment(pr, diff_size)
590
+ comment = <<~COMMENT
591
+ #{COMMENT_HEADER}
592
+
593
+ âš ī¸ PR diff is too large for automated change requests.
594
+
595
+ **Current size:** #{diff_size} lines
596
+ **Maximum allowed:** #{@config[:max_diff_size]} lines
597
+
598
+ For large PRs, please consider:
599
+ 1. Breaking the PR into smaller chunks
600
+ 2. Implementing changes manually
601
+ 3. Increasing `max_diff_size` in your `aidp.yml` configuration if appropriate
602
+ COMMENT
603
+
604
+ begin
605
+ @repository_client.post_comment(pr[:number], comment)
606
+ @repository_client.remove_labels(pr[:number], @change_request_label)
607
+ rescue
608
+ nil
609
+ end
610
+ end
611
+
612
+ def run_git(args, allow_failure: false)
613
+ stdout, stderr, status = Open3.capture3("git", *Array(args))
614
+ raise "git #{args.join(" ")} failed: #{stderr.strip}" unless status.success? || allow_failure
615
+ stdout
616
+ end
617
+
618
+ def detect_default_provider
619
+ config_manager = Aidp::Harness::ConfigManager.new(@project_dir)
620
+ config_manager.default_provider || "anthropic"
621
+ rescue
622
+ "anthropic"
623
+ end
624
+
625
+ def extract_json(text)
626
+ # Try to extract JSON from code fences or find JSON object
627
+ return text if text.start_with?("{") && text.end_with?("}")
628
+
629
+ # Extract from code fence
630
+ fence_start = text.index("```json")
631
+ if fence_start
632
+ json_start = text.index("{", fence_start)
633
+ fence_end = text.index("```", fence_start + 7)
634
+ if json_start && fence_end && json_start < fence_end
635
+ json_end = text.rindex("}", fence_end - 1)
636
+ return text[json_start..json_end] if json_end && json_end > json_start
637
+ end
638
+ end
639
+
640
+ # Find JSON object
641
+ first_brace = text.index("{")
642
+ last_brace = text.rindex("}")
643
+ if first_brace && last_brace && last_brace > first_brace
644
+ text[first_brace..last_brace]
645
+ else
646
+ text
647
+ end
648
+ end
649
+
650
+ def symbolize_keys(hash)
651
+ return {} unless hash
652
+
653
+ hash.each_with_object({}) do |(key, value), memo|
654
+ memo[key.to_sym] = value
655
+ end
656
+ end
657
+ end
658
+ end
659
+ end