aidp 0.24.0 → 0.25.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -1
  3. data/lib/aidp/auto_update/bundler_adapter.rb +66 -0
  4. data/lib/aidp/auto_update/checkpoint.rb +178 -0
  5. data/lib/aidp/auto_update/checkpoint_store.rb +182 -0
  6. data/lib/aidp/auto_update/coordinator.rb +204 -0
  7. data/lib/aidp/auto_update/errors.rb +17 -0
  8. data/lib/aidp/auto_update/failure_tracker.rb +162 -0
  9. data/lib/aidp/auto_update/rubygems_api_adapter.rb +95 -0
  10. data/lib/aidp/auto_update/update_check.rb +106 -0
  11. data/lib/aidp/auto_update/update_logger.rb +143 -0
  12. data/lib/aidp/auto_update/update_policy.rb +109 -0
  13. data/lib/aidp/auto_update/version_detector.rb +144 -0
  14. data/lib/aidp/auto_update.rb +52 -0
  15. data/lib/aidp/cli.rb +165 -1
  16. data/lib/aidp/harness/config_schema.rb +50 -0
  17. data/lib/aidp/harness/provider_factory.rb +2 -0
  18. data/lib/aidp/message_display.rb +10 -2
  19. data/lib/aidp/prompt_optimization/style_guide_indexer.rb +3 -1
  20. data/lib/aidp/provider_manager.rb +2 -0
  21. data/lib/aidp/providers/kilocode.rb +202 -0
  22. data/lib/aidp/setup/provider_registry.rb +15 -0
  23. data/lib/aidp/setup/wizard.rb +12 -4
  24. data/lib/aidp/skills/composer.rb +4 -0
  25. data/lib/aidp/skills/loader.rb +3 -1
  26. data/lib/aidp/version.rb +1 -1
  27. data/lib/aidp/watch/build_processor.rb +66 -16
  28. data/lib/aidp/watch/ci_fix_processor.rb +448 -0
  29. data/lib/aidp/watch/plan_processor.rb +12 -2
  30. data/lib/aidp/watch/repository_client.rb +380 -0
  31. data/lib/aidp/watch/review_processor.rb +266 -0
  32. data/lib/aidp/watch/reviewers/base_reviewer.rb +164 -0
  33. data/lib/aidp/watch/reviewers/performance_reviewer.rb +65 -0
  34. data/lib/aidp/watch/reviewers/security_reviewer.rb +65 -0
  35. data/lib/aidp/watch/reviewers/senior_dev_reviewer.rb +33 -0
  36. data/lib/aidp/watch/runner.rb +185 -0
  37. data/lib/aidp/watch/state_store.rb +53 -0
  38. data/lib/aidp.rb +1 -0
  39. metadata +20 -1
@@ -79,6 +79,35 @@ module Aidp
79
79
  add_labels(number, *new_labels) unless new_labels.empty?
80
80
  end
81
81
 
82
+ def most_recent_label_actor(number)
83
+ gh_available? ? most_recent_label_actor_via_gh(number) : nil
84
+ end
85
+
86
+ # PR-specific operations
87
+ def fetch_pull_request(number)
88
+ gh_available? ? fetch_pull_request_via_gh(number) : fetch_pull_request_via_api(number)
89
+ end
90
+
91
+ def fetch_pull_request_diff(number)
92
+ gh_available? ? fetch_pull_request_diff_via_gh(number) : fetch_pull_request_diff_via_api(number)
93
+ end
94
+
95
+ def fetch_pull_request_files(number)
96
+ gh_available? ? fetch_pull_request_files_via_gh(number) : fetch_pull_request_files_via_api(number)
97
+ end
98
+
99
+ def fetch_ci_status(number)
100
+ gh_available? ? fetch_ci_status_via_gh(number) : fetch_ci_status_via_api(number)
101
+ end
102
+
103
+ def post_review_comment(number, body, commit_id: nil, path: nil, line: nil)
104
+ gh_available? ? post_review_comment_via_gh(number, body, commit_id: commit_id, path: path, line: line) : post_review_comment_via_api(number, body, commit_id: commit_id, path: path, line: line)
105
+ end
106
+
107
+ def list_pull_requests(labels: [], state: "open")
108
+ gh_available? ? list_pull_requests_via_gh(labels: labels, state: state) : list_pull_requests_via_api(labels: labels, state: state)
109
+ end
110
+
82
111
  private
83
112
 
84
113
  def list_issues_via_gh(labels:, state:)
@@ -254,6 +283,357 @@ module Aidp
254
283
  end
255
284
  end
256
285
 
286
+ def most_recent_label_actor_via_gh(number)
287
+ # Use GitHub GraphQL API via gh cli to fetch the most recent label event actor
288
+ query = <<~GRAPHQL
289
+ query($owner: String!, $repo: String!, $number: Int!) {
290
+ repository(owner: $owner, name: $repo) {
291
+ issue(number: $number) {
292
+ timelineItems(last: 100, itemTypes: [LABELED_EVENT]) {
293
+ nodes {
294
+ ... on LabeledEvent {
295
+ createdAt
296
+ actor {
297
+ login
298
+ }
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ GRAPHQL
306
+
307
+ cmd = [
308
+ "gh", "api", "graphql",
309
+ "-f", "query=#{query}",
310
+ "-F", "owner=#{owner}",
311
+ "-F", "repo=#{repo}",
312
+ "-F", "number=#{number}"
313
+ ]
314
+
315
+ stdout, stderr, status = Open3.capture3(*cmd)
316
+ unless status.success?
317
+ Aidp.log_warn("repository_client", "Failed to fetch label events via GraphQL", error: stderr.strip)
318
+ return nil
319
+ end
320
+
321
+ data = JSON.parse(stdout)
322
+ events = data.dig("data", "repository", "issue", "timelineItems", "nodes") || []
323
+
324
+ # Filter out events without actors and sort by createdAt to get most recent
325
+ valid_events = events.select { |event| event.dig("actor", "login") }
326
+ return nil if valid_events.empty?
327
+
328
+ most_recent = valid_events.max_by { |event| event["createdAt"] }
329
+ most_recent.dig("actor", "login")
330
+ rescue JSON::ParserError => e
331
+ Aidp.log_warn("repository_client", "Failed to parse GraphQL response", error: e.message)
332
+ nil
333
+ rescue => e
334
+ Aidp.log_warn("repository_client", "Unexpected error fetching label actor", error: e.message)
335
+ nil
336
+ end
337
+
338
+ # PR operations via gh CLI
339
+ def list_pull_requests_via_gh(labels:, state:)
340
+ json_fields = %w[number title labels updatedAt state url headRefName baseRefName]
341
+ cmd = ["gh", "pr", "list", "--repo", full_repo, "--state", state, "--json", json_fields.join(",")]
342
+ labels.each do |label|
343
+ cmd += ["--label", label]
344
+ end
345
+
346
+ stdout, stderr, status = Open3.capture3(*cmd)
347
+ unless status.success?
348
+ warn("GitHub CLI PR list failed: #{stderr}")
349
+ return []
350
+ end
351
+
352
+ JSON.parse(stdout).map { |raw| normalize_pull_request(raw) }
353
+ rescue JSON::ParserError => e
354
+ warn("Failed to parse GH CLI PR list response: #{e.message}")
355
+ []
356
+ end
357
+
358
+ def list_pull_requests_via_api(labels:, state:)
359
+ label_param = labels.join(",")
360
+ uri = URI("https://api.github.com/repos/#{full_repo}/pulls?state=#{state}")
361
+ uri.query = [uri.query, "labels=#{URI.encode_www_form_component(label_param)}"].compact.join("&") unless label_param.empty?
362
+
363
+ response = Net::HTTP.get_response(uri)
364
+ return [] unless response.code == "200"
365
+
366
+ JSON.parse(response.body).map { |raw| normalize_pull_request_api(raw) }
367
+ rescue => e
368
+ warn("GitHub API PR list failed: #{e.message}")
369
+ []
370
+ end
371
+
372
+ def fetch_pull_request_via_gh(number)
373
+ fields = %w[number title body labels state url headRefName baseRefName commits author mergeable]
374
+ cmd = ["gh", "pr", "view", number.to_s, "--repo", full_repo, "--json", fields.join(",")]
375
+
376
+ stdout, stderr, status = Open3.capture3(*cmd)
377
+ raise "GitHub CLI error: #{stderr.strip}" unless status.success?
378
+
379
+ data = JSON.parse(stdout)
380
+ normalize_pull_request_detail(data)
381
+ rescue JSON::ParserError => e
382
+ raise "Failed to parse GitHub CLI PR response: #{e.message}"
383
+ end
384
+
385
+ def fetch_pull_request_via_api(number)
386
+ uri = URI("https://api.github.com/repos/#{full_repo}/pulls/#{number}")
387
+ response = Net::HTTP.get_response(uri)
388
+ raise "GitHub API error (#{response.code})" unless response.code == "200"
389
+
390
+ data = JSON.parse(response.body)
391
+ normalize_pull_request_detail_api(data)
392
+ end
393
+
394
+ def fetch_pull_request_diff_via_gh(number)
395
+ cmd = ["gh", "pr", "diff", number.to_s, "--repo", full_repo]
396
+ stdout, stderr, status = Open3.capture3(*cmd)
397
+ raise "Failed to fetch PR diff via gh: #{stderr.strip}" unless status.success?
398
+
399
+ stdout
400
+ end
401
+
402
+ def fetch_pull_request_diff_via_api(number)
403
+ uri = URI("https://api.github.com/repos/#{full_repo}/pulls/#{number}")
404
+ request = Net::HTTP::Get.new(uri)
405
+ request["Accept"] = "application/vnd.github.v3.diff"
406
+
407
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
408
+ http.request(request)
409
+ end
410
+
411
+ raise "GitHub API diff failed (#{response.code})" unless response.code == "200"
412
+ response.body
413
+ end
414
+
415
+ def fetch_pull_request_files_via_gh(number)
416
+ # Use gh api to fetch changed files
417
+ cmd = ["gh", "api", "repos/#{full_repo}/pulls/#{number}/files", "--jq", "."]
418
+ stdout, stderr, status = Open3.capture3(*cmd)
419
+ raise "Failed to fetch PR files via gh: #{stderr.strip}" unless status.success?
420
+
421
+ JSON.parse(stdout).map { |file| normalize_pr_file(file) }
422
+ rescue JSON::ParserError => e
423
+ raise "Failed to parse PR files response: #{e.message}"
424
+ end
425
+
426
+ def fetch_pull_request_files_via_api(number)
427
+ uri = URI("https://api.github.com/repos/#{full_repo}/pulls/#{number}/files")
428
+ response = Net::HTTP.get_response(uri)
429
+ raise "GitHub API files failed (#{response.code})" unless response.code == "200"
430
+
431
+ JSON.parse(response.body).map { |file| normalize_pr_file(file) }
432
+ rescue JSON::ParserError => e
433
+ raise "Failed to parse PR files response: #{e.message}"
434
+ end
435
+
436
+ def fetch_ci_status_via_gh(number)
437
+ # Fetch PR to get the head SHA
438
+ pr_data = fetch_pull_request_via_gh(number)
439
+ head_sha = pr_data[:head_sha]
440
+
441
+ # Fetch check runs for the commit
442
+ cmd = ["gh", "api", "repos/#{full_repo}/commits/#{head_sha}/check-runs", "--jq", "."]
443
+ stdout, _stderr, status = Open3.capture3(*cmd)
444
+
445
+ if status.success?
446
+ data = JSON.parse(stdout)
447
+ check_runs = data["check_runs"] || []
448
+ normalize_ci_status(check_runs, head_sha)
449
+ else
450
+ # Fallback to status checks
451
+ {sha: head_sha, state: "unknown", checks: []}
452
+ end
453
+ rescue => e
454
+ Aidp.log_warn("repository_client", "Failed to fetch CI status", error: e.message)
455
+ {sha: nil, state: "unknown", checks: []}
456
+ end
457
+
458
+ def fetch_ci_status_via_api(number)
459
+ pr_data = fetch_pull_request_via_api(number)
460
+ head_sha = pr_data[:head_sha]
461
+
462
+ uri = URI("https://api.github.com/repos/#{full_repo}/commits/#{head_sha}/check-runs")
463
+ request = Net::HTTP::Get.new(uri)
464
+ request["Accept"] = "application/vnd.github.v3+json"
465
+
466
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
467
+ http.request(request)
468
+ end
469
+
470
+ if response.code == "200"
471
+ data = JSON.parse(response.body)
472
+ check_runs = data["check_runs"] || []
473
+ normalize_ci_status(check_runs, head_sha)
474
+ else
475
+ {sha: head_sha, state: "unknown", checks: []}
476
+ end
477
+ rescue => e
478
+ Aidp.log_warn("repository_client", "Failed to fetch CI status", error: e.message)
479
+ {sha: nil, state: "unknown", checks: []}
480
+ end
481
+
482
+ def post_review_comment_via_gh(number, body, commit_id: nil, path: nil, line: nil)
483
+ if path && line && commit_id
484
+ # Note: gh CLI doesn't support inline comments directly, so we use the API
485
+ # For inline comments, we need to use the GitHub API
486
+ post_review_comment_via_api(number, body, commit_id: commit_id, path: path, line: line)
487
+ else
488
+ # Post general review comment
489
+ cmd = ["gh", "pr", "comment", number.to_s, "--repo", full_repo, "--body", body]
490
+ stdout, stderr, status = Open3.capture3(*cmd)
491
+ raise "Failed to post review comment via gh: #{stderr.strip}" unless status.success?
492
+
493
+ stdout.strip
494
+ end
495
+ end
496
+
497
+ def post_review_comment_via_api(number, body, commit_id: nil, path: nil, line: nil)
498
+ uri, request = if path && line && commit_id
499
+ # Post inline review comment
500
+ review_uri = URI("https://api.github.com/repos/#{full_repo}/pulls/#{number}/reviews")
501
+ review_request = Net::HTTP::Post.new(review_uri)
502
+ review_request["Content-Type"] = "application/json"
503
+ review_request["Accept"] = "application/vnd.github.v3+json"
504
+
505
+ review_data = {
506
+ body: body,
507
+ event: "COMMENT",
508
+ comments: [
509
+ {
510
+ path: path,
511
+ line: line,
512
+ body: body
513
+ }
514
+ ]
515
+ }
516
+
517
+ review_request.body = JSON.dump(review_data)
518
+ [review_uri, review_request]
519
+ else
520
+ # Post general comment on the PR
521
+ comment_uri = URI("https://api.github.com/repos/#{full_repo}/issues/#{number}/comments")
522
+ comment_request = Net::HTTP::Post.new(comment_uri)
523
+ comment_request["Content-Type"] = "application/json"
524
+ comment_request.body = JSON.dump({body: body})
525
+ [comment_uri, comment_request]
526
+ end
527
+
528
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
529
+ http.request(request)
530
+ end
531
+
532
+ error_msg = (path && line && commit_id) ? "GitHub API review comment failed (#{response.code}): #{response.body}" : "GitHub API comment failed (#{response.code})"
533
+ raise error_msg unless response.code.start_with?("2")
534
+
535
+ response.body
536
+ end
537
+
538
+ # Normalization methods for PRs
539
+ def normalize_pull_request(raw)
540
+ {
541
+ number: raw["number"],
542
+ title: raw["title"],
543
+ labels: Array(raw["labels"]).map { |label| label.is_a?(Hash) ? label["name"] : label },
544
+ updated_at: raw["updatedAt"],
545
+ state: raw["state"],
546
+ url: raw["url"],
547
+ head_ref: raw["headRefName"],
548
+ base_ref: raw["baseRefName"]
549
+ }
550
+ end
551
+
552
+ def normalize_pull_request_api(raw)
553
+ {
554
+ number: raw["number"],
555
+ title: raw["title"],
556
+ labels: Array(raw["labels"]).map { |label| label["name"] },
557
+ updated_at: raw["updated_at"],
558
+ state: raw["state"],
559
+ url: raw["html_url"],
560
+ head_ref: raw.dig("head", "ref"),
561
+ base_ref: raw.dig("base", "ref")
562
+ }
563
+ end
564
+
565
+ def normalize_pull_request_detail(raw)
566
+ {
567
+ number: raw["number"],
568
+ title: raw["title"],
569
+ body: raw["body"] || "",
570
+ author: raw.dig("author", "login") || raw["author"],
571
+ labels: Array(raw["labels"]).map { |label| label.is_a?(Hash) ? label["name"] : label },
572
+ state: raw["state"],
573
+ url: raw["url"],
574
+ head_ref: raw["headRefName"],
575
+ base_ref: raw["baseRefName"],
576
+ head_sha: raw.dig("commits", 0, "oid") || raw["headRefOid"],
577
+ mergeable: raw["mergeable"]
578
+ }
579
+ end
580
+
581
+ def normalize_pull_request_detail_api(raw)
582
+ {
583
+ number: raw["number"],
584
+ title: raw["title"],
585
+ body: raw["body"] || "",
586
+ author: raw.dig("user", "login"),
587
+ labels: Array(raw["labels"]).map { |label| label["name"] },
588
+ state: raw["state"],
589
+ url: raw["html_url"],
590
+ head_ref: raw.dig("head", "ref"),
591
+ base_ref: raw.dig("base", "ref"),
592
+ head_sha: raw.dig("head", "sha"),
593
+ mergeable: raw["mergeable"]
594
+ }
595
+ end
596
+
597
+ def normalize_pr_file(raw)
598
+ {
599
+ filename: raw["filename"],
600
+ status: raw["status"],
601
+ additions: raw["additions"],
602
+ deletions: raw["deletions"],
603
+ changes: raw["changes"],
604
+ patch: raw["patch"]
605
+ }
606
+ end
607
+
608
+ def normalize_ci_status(check_runs, head_sha)
609
+ checks = check_runs.map do |run|
610
+ {
611
+ name: run["name"],
612
+ status: run["status"],
613
+ conclusion: run["conclusion"],
614
+ details_url: run["details_url"],
615
+ output: run["output"]
616
+ }
617
+ end
618
+
619
+ # Determine overall state
620
+ state = if checks.any? { |c| c[:conclusion] == "failure" }
621
+ "failure"
622
+ elsif checks.any? { |c| c[:status] != "completed" }
623
+ "pending"
624
+ elsif checks.all? { |c| c[:conclusion] == "success" }
625
+ "success"
626
+ else
627
+ "unknown"
628
+ end
629
+
630
+ {
631
+ sha: head_sha,
632
+ state: state,
633
+ checks: checks
634
+ }
635
+ end
636
+
257
637
  def normalize_issue(raw)
258
638
  {
259
639
  number: raw["number"],
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ require_relative "../message_display"
8
+ require_relative "reviewers/senior_dev_reviewer"
9
+ require_relative "reviewers/security_reviewer"
10
+ require_relative "reviewers/performance_reviewer"
11
+
12
+ module Aidp
13
+ module Watch
14
+ # Handles the aidp-review label trigger by performing multi-persona code review
15
+ # and posting categorized findings back to the PR.
16
+ class ReviewProcessor
17
+ include Aidp::MessageDisplay
18
+
19
+ # Default label names
20
+ DEFAULT_REVIEW_LABEL = "aidp-review"
21
+
22
+ COMMENT_HEADER = "## 🤖 AIDP Code Review"
23
+
24
+ attr_reader :review_label
25
+
26
+ def initialize(repository_client:, state_store:, provider_name: nil, project_dir: Dir.pwd, label_config: {}, verbose: false)
27
+ @repository_client = repository_client
28
+ @state_store = state_store
29
+ @provider_name = provider_name
30
+ @project_dir = project_dir
31
+ @verbose = verbose
32
+
33
+ # Load label configuration
34
+ @review_label = label_config[:review_trigger] || label_config["review_trigger"] || DEFAULT_REVIEW_LABEL
35
+
36
+ # Initialize reviewers
37
+ @reviewers = [
38
+ Reviewers::SeniorDevReviewer.new(provider_name: provider_name),
39
+ Reviewers::SecurityReviewer.new(provider_name: provider_name),
40
+ Reviewers::PerformanceReviewer.new(provider_name: provider_name)
41
+ ]
42
+ end
43
+
44
+ def process(pr)
45
+ number = pr[:number]
46
+
47
+ if @state_store.review_processed?(number)
48
+ display_message("ℹ️ Review for PR ##{number} already posted. Skipping.", type: :muted)
49
+ return
50
+ end
51
+
52
+ display_message("🔍 Reviewing PR ##{number} (#{pr[:title]})", type: :info)
53
+
54
+ # Fetch PR details
55
+ pr_data = @repository_client.fetch_pull_request(number)
56
+ files = @repository_client.fetch_pull_request_files(number)
57
+ diff = @repository_client.fetch_pull_request_diff(number)
58
+
59
+ # Run reviews in parallel (conceptually - actual implementation is sequential)
60
+ review_results = run_reviews(pr_data: pr_data, files: files, diff: diff)
61
+
62
+ # Log review results
63
+ log_review(number, review_results)
64
+
65
+ # Format and post comment
66
+ comment_body = format_review_comment(pr: pr_data, review_results: review_results)
67
+ @repository_client.post_comment(number, comment_body)
68
+
69
+ display_message("💬 Posted review comment for PR ##{number}", type: :success)
70
+ @state_store.record_review(number, {
71
+ timestamp: Time.now.utc.iso8601,
72
+ reviewers: review_results.map { |r| r[:persona] },
73
+ total_findings: review_results.sum { |r| r[:findings].length }
74
+ })
75
+
76
+ # Remove review label after processing
77
+ begin
78
+ @repository_client.remove_labels(number, @review_label)
79
+ display_message("🏷️ Removed '#{@review_label}' label after review", type: :info)
80
+ rescue => e
81
+ display_message("⚠️ Failed to remove review label: #{e.message}", type: :warn)
82
+ end
83
+ rescue => e
84
+ display_message("❌ Review failed: #{e.message}", type: :error)
85
+ Aidp.log_error("review_processor", "Review failed", pr: number, error: e.message, backtrace: e.backtrace&.first(10))
86
+
87
+ # Post error comment
88
+ error_comment = <<~COMMENT
89
+ #{COMMENT_HEADER}
90
+
91
+ ❌ Automated review failed: #{e.message}
92
+
93
+ Please review manually or retry by re-adding the `#{@review_label}` label.
94
+ COMMENT
95
+ begin
96
+ @repository_client.post_comment(number, error_comment)
97
+ rescue
98
+ nil
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def run_reviews(pr_data:, files:, diff:)
105
+ results = []
106
+
107
+ @reviewers.each do |reviewer|
108
+ display_message(" Running #{reviewer.persona_name} review...", type: :muted) if @verbose
109
+
110
+ begin
111
+ result = reviewer.review(pr_data: pr_data, files: files, diff: diff)
112
+ results << result
113
+
114
+ findings_count = result[:findings].length
115
+ display_message(" ✓ #{reviewer.persona_name}: #{findings_count} findings", type: :muted) if @verbose
116
+ rescue => e
117
+ display_message(" ✗ #{reviewer.persona_name} failed: #{e.message}", type: :warn)
118
+ Aidp.log_error("review_processor", "Reviewer failed", reviewer: reviewer.persona_name, error: e.message)
119
+ # Continue with other reviewers
120
+ end
121
+ end
122
+
123
+ results
124
+ end
125
+
126
+ def format_review_comment(pr:, review_results:)
127
+ parts = []
128
+ parts << COMMENT_HEADER
129
+ parts << ""
130
+ parts << "Automated multi-persona code review for PR ##{pr[:number]}"
131
+ parts << ""
132
+
133
+ # Collect all findings by severity
134
+ all_findings = collect_findings_by_severity(review_results)
135
+
136
+ if all_findings.empty?
137
+ parts << "✅ **No issues found!** All reviewers approved the changes."
138
+ parts << ""
139
+ parts << "_The code looks good from architecture, security, and performance perspectives._"
140
+ else
141
+ parts << "### Summary"
142
+ parts << ""
143
+ parts << "| Severity | Count |"
144
+ parts << "|----------|-------|"
145
+ parts << "| 🔴 High Priority | #{all_findings[:high].length} |"
146
+ parts << "| 🟠 Major | #{all_findings[:major].length} |"
147
+ parts << "| 🟡 Minor | #{all_findings[:minor].length} |"
148
+ parts << "| ⚪ Nit | #{all_findings[:nit].length} |"
149
+ parts << ""
150
+
151
+ # Add findings by severity
152
+ if all_findings[:high].any?
153
+ parts << "### 🔴 High Priority Issues"
154
+ parts << ""
155
+ parts << format_findings(all_findings[:high])
156
+ parts << ""
157
+ end
158
+
159
+ if all_findings[:major].any?
160
+ parts << "### 🟠 Major Issues"
161
+ parts << ""
162
+ parts << format_findings(all_findings[:major])
163
+ parts << ""
164
+ end
165
+
166
+ if all_findings[:minor].any?
167
+ parts << "### 🟡 Minor Improvements"
168
+ parts << ""
169
+ parts << format_findings(all_findings[:minor])
170
+ parts << ""
171
+ end
172
+
173
+ if all_findings[:nit].any?
174
+ parts << "<details>"
175
+ parts << "<summary>⚪ Nit-picks (click to expand)</summary>"
176
+ parts << ""
177
+ parts << format_findings(all_findings[:nit])
178
+ parts << ""
179
+ parts << "</details>"
180
+ parts << ""
181
+ end
182
+ end
183
+
184
+ # Add reviewer attribution
185
+ parts << "---"
186
+ parts << "_Reviewed by: #{review_results.map { |r| r[:persona] }.join(", ")}_"
187
+
188
+ parts.join("\n")
189
+ end
190
+
191
+ def collect_findings_by_severity(review_results)
192
+ findings = {high: [], major: [], minor: [], nit: []}
193
+
194
+ review_results.each do |result|
195
+ persona = result[:persona]
196
+ result[:findings].each do |finding|
197
+ severity = finding["severity"]&.to_sym || :minor
198
+ findings[severity] << finding.merge("reviewer" => persona)
199
+ end
200
+ end
201
+
202
+ findings
203
+ end
204
+
205
+ def format_findings(findings)
206
+ findings.map do |finding|
207
+ parts = []
208
+
209
+ # Header with category and reviewer
210
+ header = "**#{finding["category"]}**"
211
+ header += " (#{finding["reviewer"]})" if finding["reviewer"]
212
+ parts << header
213
+
214
+ # Location if available
215
+ if finding["file"]
216
+ location = "`#{finding["file"]}"
217
+ location += ":#{finding["line"]}" if finding["line"]
218
+ location += "`"
219
+ parts << location
220
+ end
221
+
222
+ # Message
223
+ parts << finding["message"]
224
+
225
+ # Suggestion if available
226
+ if finding["suggestion"]
227
+ parts << ""
228
+ parts << "<details>"
229
+ parts << "<summary>💡 Suggested fix</summary>"
230
+ parts << ""
231
+ parts << "```suggestion"
232
+ parts << finding["suggestion"]
233
+ parts << "```"
234
+ parts << "</details>"
235
+ end
236
+
237
+ parts.join("\n")
238
+ end.join("\n\n")
239
+ end
240
+
241
+ def log_review(pr_number, review_results)
242
+ log_dir = File.join(@project_dir, ".aidp", "logs", "pr_reviews")
243
+ FileUtils.mkdir_p(log_dir)
244
+
245
+ log_file = File.join(log_dir, "pr_#{pr_number}_#{Time.now.utc.strftime("%Y%m%d_%H%M%S")}.json")
246
+
247
+ log_data = {
248
+ pr_number: pr_number,
249
+ timestamp: Time.now.utc.iso8601,
250
+ reviews: review_results.map do |result|
251
+ {
252
+ persona: result[:persona],
253
+ findings_count: result[:findings].length,
254
+ findings: result[:findings]
255
+ }
256
+ end
257
+ }
258
+
259
+ File.write(log_file, JSON.pretty_generate(log_data))
260
+ display_message("📝 Review log saved to #{log_file}", type: :muted) if @verbose
261
+ rescue => e
262
+ display_message("⚠️ Failed to save review log: #{e.message}", type: :warn)
263
+ end
264
+ end
265
+ end
266
+ end