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.
- checksums.yaml +4 -4
- data/README.md +27 -1
- data/lib/aidp/auto_update/bundler_adapter.rb +66 -0
- data/lib/aidp/auto_update/checkpoint.rb +178 -0
- data/lib/aidp/auto_update/checkpoint_store.rb +182 -0
- data/lib/aidp/auto_update/coordinator.rb +204 -0
- data/lib/aidp/auto_update/errors.rb +17 -0
- data/lib/aidp/auto_update/failure_tracker.rb +162 -0
- data/lib/aidp/auto_update/rubygems_api_adapter.rb +95 -0
- data/lib/aidp/auto_update/update_check.rb +106 -0
- data/lib/aidp/auto_update/update_logger.rb +143 -0
- data/lib/aidp/auto_update/update_policy.rb +109 -0
- data/lib/aidp/auto_update/version_detector.rb +144 -0
- data/lib/aidp/auto_update.rb +52 -0
- data/lib/aidp/cli.rb +165 -1
- data/lib/aidp/harness/config_schema.rb +50 -0
- data/lib/aidp/harness/provider_factory.rb +2 -0
- data/lib/aidp/message_display.rb +10 -2
- data/lib/aidp/prompt_optimization/style_guide_indexer.rb +3 -1
- data/lib/aidp/provider_manager.rb +2 -0
- data/lib/aidp/providers/kilocode.rb +202 -0
- data/lib/aidp/setup/provider_registry.rb +15 -0
- data/lib/aidp/setup/wizard.rb +12 -4
- data/lib/aidp/skills/composer.rb +4 -0
- data/lib/aidp/skills/loader.rb +3 -1
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +66 -16
- data/lib/aidp/watch/ci_fix_processor.rb +448 -0
- data/lib/aidp/watch/plan_processor.rb +12 -2
- data/lib/aidp/watch/repository_client.rb +380 -0
- data/lib/aidp/watch/review_processor.rb +266 -0
- data/lib/aidp/watch/reviewers/base_reviewer.rb +164 -0
- data/lib/aidp/watch/reviewers/performance_reviewer.rb +65 -0
- data/lib/aidp/watch/reviewers/security_reviewer.rb +65 -0
- data/lib/aidp/watch/reviewers/senior_dev_reviewer.rb +33 -0
- data/lib/aidp/watch/runner.rb +185 -0
- data/lib/aidp/watch/state_store.rb +53 -0
- data/lib/aidp.rb +1 -0
- 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
|