ace-review 0.57.1 → 0.58.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 865eb269684936c32eb5d91b002f98419e71b1c89155715bc486382bfa4549f3
4
- data.tar.gz: 0a4bbea96d948953da5fd157c10ae83c5d6b548888663975342ae11823784195
3
+ metadata.gz: fda3bb688af1e2d4c71e6f37dc20d25c4f756703c3a2910827629975fd0965e1
4
+ data.tar.gz: a479d205b501a785063680654466e0eda84e5d00397cc74831dbffc24d106552
5
5
  SHA512:
6
- metadata.gz: 70a1f4d8f88fb5d9971e961fc0918b5c0477e87a52447b877814b429775b3196c648f2d99f24b216453d30e685ea29da97cb62b93809743a8f26f484402ec622
7
- data.tar.gz: 39a724d6327716608165c95de3955ae77528fd19baaf107cf035e7cdef2e99697647cc247797f9e72c8639a6535e962791fa12f70fe878a582c181e87e746b43
6
+ metadata.gz: b5d61e56ef9b7c8cd02a8c44130e4c0d43266d3327290e963136f36b442a596d6070b9736d94f46427265be4696bc43c4d0e8c69142eaa4fd5019a0e6db31b9c
7
+ data.tar.gz: 0dde4d996c5f713b5f5babfe57f03814cfe658d217207071da16f8248e387e14bad4916184e8175c0f603842efac319636a73d5e1394b6b92a7c29dea072b208
@@ -9,7 +9,7 @@
9
9
  # context - Default context preset (default: "project")
10
10
  # max_concurrent_models - Max parallel LLM queries for multi-model execution (default: 3)
11
11
  # auto_execute - Auto-execute LLM query without confirmation (default: false)
12
- # gh_timeout - Timeout for GitHub CLI operations in seconds (default: 30)
12
+ # provider_timeout - Timeout for forge operations in seconds (default: 30)
13
13
  # gh_simple_timeout - Timeout for simple gh commands like --version, auth status (default: 10)
14
14
  # llm_timeout - Timeout for LLM queries in seconds (default: 900)
15
15
  #
@@ -37,7 +37,7 @@ defaults:
37
37
  bundle: "project"
38
38
  max_concurrent_models: 3 # Max parallel LLM queries for multi-model execution
39
39
  auto_execute: false # Set to true to auto-execute LLM query without confirmation
40
- gh_timeout: 30 # Timeout for GitHub CLI operations in seconds
40
+ provider_timeout: 30 # Timeout for forge operations in seconds
41
41
  gh_simple_timeout: 10 # Timeout for simple gh commands (--version, auth status)
42
42
  llm_timeout: 900 # Timeout for LLM queries in seconds (15 minutes)
43
43
 
data/CHANGELOG.md CHANGED
@@ -7,11 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.57.1] - 2026-10-02
10
+ ## [0.58.0] - 2026-10-02
11
11
 
12
- ### Fixed
12
+ ### Added
13
+ - Provider-neutral PR reviews and comment handling for GitHub, default Forgejo, and named Forgejo servers with exact-head evidence.
13
14
 
14
- - A failed or incomplete provider entry no longer invalidates a campaign session's completed reviewer executions; one completed reviewer per needed scope still completes the round.
15
+ ### Changed
16
+ - Replaced `--gh-timeout` with `--provider-timeout`; removed the GitHub-only review fetcher, poster, and resolver paths.
17
+ - Preserve provider and repository identity across review sessions and comments; fail closed on moved heads and collection errors.
15
18
 
16
19
 
17
20
  ## [0.57.0] - 2026-10-01
@@ -23,7 +23,7 @@ Key benefits:
23
23
 
24
24
  1. Run a review with feedback extraction:
25
25
  ```bash
26
- ace-review --preset code-pr --pr 123 --auto-execute
26
+ ace-review --preset code-valid --pr 123 --auto-execute
27
27
  ```
28
28
 
29
29
  2. List feedback items:
@@ -245,7 +245,7 @@ feedback:
245
245
  ### Example Workflow Configuration
246
246
 
247
247
  ```yaml
248
- # .ace/review/presets/code-pr.yml
248
+ # .ace/review/presets/code-valid.yml
249
249
  presets:
250
250
  - code
251
251
 
@@ -416,10 +416,10 @@ Each review execution creates a new session:
416
416
 
417
417
  ```bash
418
418
  # First review
419
- ace-review --pr 189 --preset code-pr
419
+ ace-review --pr 189 --preset code-valid
420
420
 
421
421
  # Second review (different PR or updated code)
422
- ace-review --pr 190 --preset code-pr
422
+ ace-review --pr 190 --preset code-valid
423
423
 
424
424
  # List all sessions
425
425
  ls -la .ace-local/review/sessions/
@@ -38,7 +38,7 @@ This runs a code-focused review against your current branch diff and writes sess
38
38
  Run:
39
39
 
40
40
  ```bash
41
- ace-review --pr 123 --preset code-pr --auto-execute
41
+ ace-review --pr 123 --preset code-valid --auto-execute
42
42
  ```
43
43
 
44
44
  Use `--post-comment` when you want to publish the review back to GitHub.
data/docs/usage.md CHANGED
@@ -29,7 +29,7 @@ ace-review [OPTIONS]
29
29
 
30
30
  | Option | Description |
31
31
  |--------|-------------|
32
- | `--preset` | Review preset: code, code-pr, security, performance, docs |
32
+ | `--preset` | Review preset from the shipped catalog, such as code-valid, code-fit, integration, or docs |
33
33
  | `--pr` | Review GitHub PR (number, URL, or owner/repo#number) |
34
34
  | `--subject` | Subject config (repeatable): `diff:range`, `diff:range -- path`, `files:glob`, `preset:name` |
35
35
  | `--context` | Context config (preset name or YAML) |
@@ -43,7 +43,8 @@ ace-review [OPTIONS]
43
43
  |--------|-------------|
44
44
  | `--pr-comments` | Include PR comments as feedback source (default: true for --pr) |
45
45
  | `--post-comment` | Post review as PR comment (requires --pr) |
46
- | `--gh-timeout` | Timeout for gh CLI operations in seconds (default: 30) |
46
+ | `--server` / `--default-server` | Select a configured forge server for a PR review |
47
+ | `--provider-timeout` | Timeout for forge operations in seconds (default: 30) |
47
48
 
48
49
  ### Prompt Composition
49
50
 
@@ -20,20 +20,37 @@ module Ace
20
20
 
21
21
  # Format comments data into markdown report
22
22
  #
23
- # @param comments_data [Hash] Data from GhPrCommentFetcher
23
+ # @param comments_data [Hash] normalized provider comment data
24
24
  # @return [String] Formatted markdown report
25
25
  def self.format(comments_data)
26
26
  return nil unless comments_data && comments_data[:success]
27
27
 
28
28
  pr_number = comments_data[:pr_number]
29
29
  pr_title = comments_data[:pr_title]
30
- comments = comments_data[:comments] || []
31
- reviews = comments_data[:reviews] || []
30
+ # Full provider evidence is preserved upstream; prompt feedback
31
+ # excludes bot posts, blank bodies, and our own session comments
32
+ # (they are not unresolved human feedback).
33
+ comments = (comments_data[:comments] || []).reject do |comment|
34
+ author = comment[:author].to_s
35
+ body = comment[:body].to_s
36
+ author.match?(/\b(bot|github-actions|ace-review)\b/i) ||
37
+ body.empty? ||
38
+ own_session_post?(body)
39
+ end
40
+ reviews = (comments_data[:reviews] || []).reject do |review|
41
+ review[:author].to_s.match?(/\b(bot|github-actions|ace-review)\b/i)
42
+ end
32
43
  review_threads = comments_data[:review_threads] || []
33
44
 
34
45
  # Build report sections
35
46
  frontmatter = build_frontmatter(comments_data)
36
47
  summary = build_summary(comments, reviews, review_threads, pr_number, pr_title)
48
+ # Apply the same bot filter to thread comments; threads left
49
+ # without human comments are omitted from feedback.
50
+ review_threads = review_threads.map do |thread|
51
+ filtered = thread.merge(comments: Array(thread[:comments]).reject { |c| c[:author].to_s.match?(/\b(bot|github-actions|ace-review)\b/i) })
52
+ filtered.merge(comments: filtered[:comments])
53
+ end.reject { |thread| Array(thread[:comments]).empty? }
37
54
  inline_section = build_inline_comments_section(review_threads)
38
55
  unresolved_section = build_unresolved_section(comments, reviews)
39
56
  resolved_section = build_resolved_section(reviews)
@@ -76,6 +93,7 @@ module Ace
76
93
  inline_thread_count = review_threads.size
77
94
  reviewers = extract_reviewers(comments, reviews, review_threads)
78
95
  unresolved_count = count_unresolved(comments, reviews, review_threads)
96
+ unknown_resolution = review_threads.count { |t| t[:is_resolved].nil? }
79
97
 
80
98
  summary = "# Developer Feedback from PR ##{pr_number}\n\n"
81
99
  summary += "> #{pr_title}\n\n" if pr_title && !pr_title.empty?
@@ -83,6 +101,7 @@ module Ace
83
101
  summary += "- Total comments: #{total_comments}\n"
84
102
  summary += "- Inline code comments: #{inline_thread_count}\n" if inline_thread_count > 0
85
103
  summary += "- Unresolved items: #{unresolved_count}\n"
104
+ summary += "- Unknown-resolution threads: #{unknown_resolution}\n" if unknown_resolution > 0
86
105
  summary += "- Reviewers: #{reviewers.map { |r| "@#{r}" }.join(", ")}\n" if reviewers.any?
87
106
  summary
88
107
  end
@@ -144,7 +163,11 @@ module Ace
144
163
  line = thread[:line]
145
164
  thread_id = thread[:id]
146
165
  is_resolved = thread[:is_resolved]
147
- status = is_resolved ? "Resolved" : "Unresolved"
166
+ status = case is_resolved
167
+ when true then "Resolved"
168
+ when false then "Unresolved"
169
+ else "Resolution unknown"
170
+ end
148
171
 
149
172
  # Header with file:line, thread ID, and status
150
173
  location = line ? "#{path}:#{line}" : path
@@ -186,6 +209,14 @@ module Ace
186
209
  section
187
210
  end
188
211
 
212
+ # An ACE post carries our review header AND a well-formed session
213
+ # marker as the trailing hidden line. A human reply quoting an
214
+ # earlier comment rarely matches both, so quotes stay visible.
215
+ def self.own_session_post?(body)
216
+ return false unless body.start_with?("## Code Review - ace-review")
217
+ body.match?(/<!--\s*ace-review-session:[a-zA-Z0-9._:-]+\s*-->\s*\z/)
218
+ end
219
+
189
220
  # Build comments table for quick reference
190
221
  #
191
222
  # @param comments [Array<Hash>] Issue comments
@@ -256,7 +287,7 @@ module Ace
256
287
  # @param review_threads [Array<Hash>] Inline review threads
257
288
  # @return [Integer] Count of unresolved items
258
289
  def self.count_unresolved(comments, reviews, review_threads = [])
259
- unresolved_threads = review_threads.count { |t| !t[:is_resolved] }
290
+ unresolved_threads = review_threads.count { |t| t[:is_resolved] == false }
260
291
  actionable_comments = comments.count { |c| actionable_comment?(c[:body]) }
261
292
  actionable_comments + reviews.count { |r| r[:state] == "CHANGES_REQUESTED" } + unresolved_threads
262
293
  end
@@ -16,10 +16,11 @@ module Ace
16
16
  Execute code review using presets or custom configuration
17
17
 
18
18
  Presets provide pre-configured review types with focused prompts:
19
- code → General code review
20
- code-pr → PR-focused code review
21
- security → Security-focused review
22
- performance → Performance-focused review
19
+ code-valid → Code correctness review
20
+ code-fit → Code design review
21
+ code-shine → Code quality review
22
+ integration → Integration review
23
+ spec → Specification review
23
24
  docs → Documentation review
24
25
 
25
26
  Configuration:
@@ -31,13 +32,13 @@ module Ace
31
32
  DESC
32
33
 
33
34
  example [
34
- "--preset code-pr # PR code review",
35
- "--preset security --auto-execute # Run and apply fixes",
35
+ "--preset code-valid --pr 123 # PR code review",
36
+ "--preset code-fit --auto-execute # Run and apply fixes",
36
37
  "--pr 123 # Review by PR number",
37
38
  "--pr 123 --delta # Delta round since the most recent prior session's head",
38
39
  "--pr 123 --delta <head> # Delta round since an explicit reviewed head",
39
- "--preset code --subject diff:HEAD~3 --subject files:docs/**/*.md",
40
- "--preset security --dry-run # Preview without executing"
40
+ "--preset code-valid --subject diff:HEAD~3 --subject files:docs/**/*.md",
41
+ "--preset code-fit --dry-run # Preview without executing"
41
42
  ]
42
43
 
43
44
  # Review configuration options
@@ -58,10 +59,12 @@ module Ace
58
59
  option :auto_execute, type: :boolean, default: nil, desc: "Execute LLM query automatically"
59
60
  option :save_session, type: :boolean, desc: "Save session files (default: true)"
60
61
  option :session_dir, type: :string, desc: "Custom session directory"
61
- option :pr, type: :string, desc: "Review GitHub PR (number, URL, or owner/repo#number)"
62
+ option :pr, type: :string, desc: "Review PR (number, URL, or owner/repo#number)"
63
+ option :server, type: :string, desc: "Use the named configured forge server"
64
+ option :default_server, type: :boolean, desc: "Use the configured default forge server"
62
65
  option :pr_comments, type: :boolean, desc: "Include PR comments as feedback source (default: true for --pr)"
63
66
  option :post_comment, type: :boolean, desc: "Post review as PR comment (requires --pr)"
64
- option :gh_timeout, type: :integer, desc: "Timeout for gh CLI operations in seconds (default: 30)"
67
+ option :provider_timeout, type: :integer, desc: "Timeout for forge operations in seconds (default: 30)"
65
68
  option :evidence_session, type: :array, desc: "Include selected prior review sessions as context"
66
69
  option :delta, type: :string, optional_value: true,
67
70
  desc: "Delta round for --pr: review only changes since a reference head (explicit head, or `auto` to auto-resolve from the most recent prior session for this PR); omit for a full round"
@@ -106,7 +109,9 @@ module Ace
106
109
  end
107
110
 
108
111
  # Type-convert numeric options (ace-support-cli returns strings, Thor converted to integers)
109
- cli_options[:gh_timeout] = cli_options[:gh_timeout]&.to_i if cli_options[:gh_timeout]
112
+ if cli_options[:provider_timeout]
113
+ cli_options[:provider_timeout] = cli_options[:provider_timeout].to_i
114
+ end
110
115
 
111
116
  # Build and store options for testing compatibility
112
117
  @options = build_review_options(cli_options)
@@ -32,74 +32,6 @@ module Ace
32
32
  end
33
33
  end
34
34
 
35
- # Raised when gh CLI is not installed
36
- class GhCliNotInstalledError < Error
37
- def initialize
38
- message = "GitHub CLI (gh) is not installed.\n"
39
- message += "Install with:\n"
40
- message += " macOS: brew install gh\n"
41
- message += " Linux: See https://cli.github.com/manual/installation\n"
42
- message += " Windows: See https://cli.github.com/manual/installation"
43
-
44
- super(message)
45
- end
46
- end
47
-
48
- # Raised when user is not authenticated with GitHub
49
- class GhAuthenticationError < Error
50
- def initialize
51
- message = "GitHub authentication required.\n"
52
- message += "Run 'gh auth login' to authenticate with GitHub.\n"
53
- message += "Check status: gh auth status"
54
-
55
- super(message)
56
- end
57
- end
58
-
59
- # Raised when a PR is not found
60
- class PrNotFoundError < Error
61
- attr_reader :pr_identifier
62
-
63
- def initialize(pr_identifier, details = nil)
64
- @pr_identifier = pr_identifier
65
- message = "Pull request '#{pr_identifier}' not found."
66
- message += "\n#{details}" if details
67
-
68
- super(message)
69
- end
70
- end
71
-
72
- # Raised when PR diff exceeds GitHub's API size limit (300 files)
73
- class DiffTooLargeError < Error
74
- attr_reader :pr_identifier
75
-
76
- def initialize(pr_identifier, details = nil)
77
- @pr_identifier = pr_identifier
78
- message = "Pull request '#{pr_identifier}' diff exceeds GitHub API size limit."
79
- message += "\n#{details}" if details
80
-
81
- super(message)
82
- end
83
- end
84
-
85
- # Raised when attempting to post to a closed/merged PR
86
- class PrStateError < Error
87
- attr_reader :pr_number, :state
88
-
89
- def initialize(pr_number, state)
90
- @pr_number = pr_number
91
- @state = state
92
-
93
- message = "Cannot post comment to PR ##{pr_number}.\n"
94
- message += "PR is in '#{state}' state. Comments can only be posted to open PRs."
95
-
96
- super(message)
97
- end
98
- end
99
-
100
- # Raised when gh CLI encounters a network error
101
- class GhNetworkError < Error; end
102
-
103
35
  # Raised when ace-task cannot find task
104
36
  class TaskNotFoundError < Error
105
37
  attr_reader :task_ref
@@ -9,7 +9,8 @@ module Ace
9
9
  :prompt_base, :prompt_format, :prompt_focus, :add_focus,
10
10
  :prompt_guidelines, :model, :models, :dry_run, :verbose,
11
11
  :auto_execute, :save_session, :session_dir,
12
- :pr, :post_comment, :pr_metadata, :gh_timeout,
12
+ :pr, :post_comment, :pr_metadata, :provider_timeout,
13
+ :server, :default_server,
13
14
  :pr_comments, :pr_comment_data,
14
15
  :evidence_sessions, :campaign, :campaign_round, :campaign_scope,
15
16
  :delta,
@@ -54,7 +55,9 @@ module Ace
54
55
  @pr = hash[:pr]
55
56
  @post_comment = hash[:post_comment] || false
56
57
  @pr_metadata = hash[:pr_metadata]
57
- @gh_timeout = hash[:gh_timeout]
58
+ @provider_timeout = hash[:provider_timeout]
59
+ @server = hash[:server]
60
+ @default_server = hash[:default_server] || false
58
61
 
59
62
  # PR comment options
60
63
  @pr_comments = hash[:pr_comments] # nil = use default, true/false = explicit
@@ -195,7 +198,7 @@ module Ace
195
198
  end
196
199
  end
197
200
 
198
- @gh_timeout ||= config["gh_timeout"]
201
+ @provider_timeout ||= config["provider_timeout"]
199
202
 
200
203
  self
201
204
  end
@@ -18,16 +18,6 @@ module Ace
18
18
  class CampaignEvidence
19
19
  Contract = Atoms::CampaignContract
20
20
 
21
- # One completion predicate for provider executions, shared with the
22
- # extraction-inventory writer so session metadata and campaign
23
- # evidence can never disagree about which reports were reviewed.
24
- def self.completed_entry?(entry)
25
- execution = entry["execution"] || {}
26
- entry["status"] == "success" && execution["status"].to_s == "succeeded" &&
27
- !execution["provider"].to_s.empty? && !execution["model"].to_s.empty? &&
28
- !entry["completed_at"].to_s.empty?
29
- end
30
-
31
21
  def initialize(repo_root:, check_evidence: nil, review_evidence: nil, approval_evidence: nil)
32
22
  @repo_root = File.realpath(repo_root)
33
23
  authority = CampaignExecutionEvidence.new(repo_root: @repo_root)
@@ -67,9 +57,9 @@ module Ace
67
57
  metadata = json_keys(YAML.safe_load_file(metadata_path, permitted_classes: [Time, Date, Symbol]))
68
58
  Contract.object!(metadata, "session metadata")
69
59
  expected = {"campaign_id" => record["id"], "contract_identity" => record["contract_identity"],
70
- "subject" => record["subject"], "round_id" => binding["round_id"], "scope" => scope,
71
- "head" => binding["head"], "base" => binding["base"],
72
- "scope_identity" => binding["scope_identity"][scope]}
60
+ "subject" => record["subject"], "round_id" => binding["round_id"], "scope" => scope,
61
+ "head" => binding["head"], "base" => binding["base"],
62
+ "scope_identity" => binding["scope_identity"][scope]}
73
63
  unless metadata["campaign_binding"] == expected && metadata["preset"] == expected["scope_identity"]["preset"]
74
64
  raise Contract::Invalid, "session campaign/contract/scope/head/base identity mismatch"
75
65
  end
@@ -93,7 +83,12 @@ module Ace
93
83
  reports = []
94
84
  entries.each do |entry|
95
85
  Contract.object!(entry, "model execution")
96
- next unless self.class.completed_entry?(entry)
86
+ execution = entry["execution"] || {}
87
+ complete = entry["status"] == "success" && execution["status"] == "succeeded" &&
88
+ !execution["provider"].to_s.empty? && !execution["model"].to_s.empty? &&
89
+ !entry["completed_at"].to_s.empty?
90
+ completed &&= complete
91
+ next unless complete
97
92
  path = entry["output_file"].to_s
98
93
  raise Contract::Invalid, "completed execution has no report" if path.empty?
99
94
  path = File.expand_path(path, dir)
@@ -104,7 +99,7 @@ module Ace
104
99
  resolved, report_ref = artifact("path" => path, "sha256" => entry["report_sha256"])
105
100
  raise Contract::Invalid, "empty reviewer report" if File.read(resolved).strip.empty?
106
101
  refs << report_ref
107
- reports << {"artifact" => report_ref, "execution" => entry["execution"] || {}}
102
+ reports << {"artifact" => report_ref, "execution" => execution}
108
103
  prompts = entry["prompt_sha256"]
109
104
  Contract.object!(prompts, "execution prompt checksums")
110
105
  %w[system user].each do |name|
@@ -122,10 +117,6 @@ module Ace
122
117
  raise Contract::Invalid, "feedback extraction inventory or reviewed reports changed"
123
118
  end
124
119
  end
125
- # One completed reviewer per needed scope suffices: a failed or
126
- # incomplete provider entry does not count as a reviewer, but it
127
- # does not invalidate the model executions that did complete.
128
- completed &&= reports.any?
129
120
  completed &&= extracted
130
121
  proof = completed ? @review_evidence.call(input.fetch("receipt"), head: binding["head"], artifacts: refs) : nil
131
122
  {"receipt" => input["receipt"], "execution_proof" => proof, "path" => dir.delete_prefix(@repo_root + File::SEPARATOR), "scope" => scope,
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/git"
4
+ require "ace/git/github"
5
+ require "ace/git/forgejo"
6
+ require "ace/git/organisms/pull_request_lifecycle"
7
+ require "digest"
8
+
9
+ module Ace
10
+ module Review
11
+ module Molecules
12
+ # Review-facing projection of the shared, exact-head provider contract.
13
+ # Provider commands and response parsing remain in ace-git packages.
14
+ class PrProvider
15
+ def self.format_comment(review_text, preset: nil, model: nil, timestamp: nil)
16
+ header = "## Code Review - ace-review\n\n"
17
+ header += "**Preset**: #{preset}\n" if preset
18
+ header += "**Model**: #{model}\n" if model
19
+ header += "**Generated**: #{timestamp}\n" if timestamp
20
+ header += "\n" if preset || model || timestamp
21
+ content = review_text.to_s
22
+ content += "\n```\n" if content.scan(/^```/).count.odd?
23
+ header + <<~MARKDOWN
24
+ <details>
25
+ <summary><b>Full Review</b> (click to expand)</summary>
26
+
27
+ #{content}
28
+
29
+ </details>
30
+ MARKDOWN
31
+ end
32
+
33
+ # The forge server URL resolved by the lifecycle at construction;
34
+ # posting compares it with the reviewed repository before mutating.
35
+ def resolved_server_url(pr_identifier)
36
+ @lifecycle.resolved_server_url(pr_identifier)
37
+ end
38
+
39
+ def initialize(server_name: nil, use_default: false, timeout: nil, runner: nil, lifecycle: nil)
40
+ @lifecycle = lifecycle || Ace::Git::Organisms::PullRequestLifecycle.new(
41
+ server_name: server_name, use_default: use_default,
42
+ timeout: timeout, runner: runner
43
+ )
44
+ end
45
+
46
+ def fetch(pr_identifier, include_comments: true)
47
+ snapshot = @lifecycle.review_snapshot(pr_identifier, include_comments: include_comments)
48
+ metadata = metadata_for(snapshot)
49
+ {
50
+ success: true,
51
+ diff: snapshot.diff,
52
+ metadata: metadata,
53
+ comments: comments_for(snapshot),
54
+ checks: snapshot.checks,
55
+ snapshot: snapshot
56
+ }
57
+ rescue Ace::Git::Error, ArgumentError => e
58
+ {success: false, error: "#{e.class.name.split('::').last}: #{e.message}"}
59
+ end
60
+
61
+ # Exact-head PR metadata only (no diff, comments, or checks); the
62
+ # delta resolver needs identity and base provenance, never the
63
+ # whole diff.
64
+ def fetch_metadata(pr_identifier)
65
+ snapshot = @lifecycle.review_metadata_snapshot(pr_identifier)
66
+ {success: true, metadata: metadata_for(snapshot)}
67
+ rescue Ace::Git::Error, ArgumentError => e
68
+ {success: false, error: "#{e.class.name.split('::').last}: #{e.message}"}
69
+ end
70
+
71
+ def post_comment(pr_identifier, expected_head:, content:, session_key:)
72
+ correlation = Digest::SHA256.hexdigest(session_key.to_s)
73
+ @lifecycle.post_review_comment(
74
+ pr_identifier, expected_head: expected_head,
75
+ body: content, correlation: correlation
76
+ )
77
+ end
78
+
79
+ def resolve_thread(pr_identifier, expected_head:, thread_id:)
80
+ @lifecycle.resolve_review_thread(
81
+ pr_identifier, expected_head: expected_head, thread_id: thread_id
82
+ )
83
+ end
84
+
85
+ def update_comment(pr_identifier, expected_head:, comment_id:, body:)
86
+ @lifecycle.update_review_comment(
87
+ pr_identifier, expected_head: expected_head,
88
+ comment_id: comment_id, body: body
89
+ )
90
+ end
91
+
92
+ def file_at_ref(pr_identifier, path:, ref:)
93
+ @lifecycle.file_at_ref(pr_identifier, path: path, ref: ref)
94
+ end
95
+
96
+ private
97
+
98
+ def metadata_for(snapshot)
99
+ pr = snapshot.pull_request
100
+ {
101
+ "server_name" => pr.server_name,
102
+ "provider" => snapshot.provider.to_s,
103
+ "repository_url" => pr.base_repository_url,
104
+ "number" => pr.number,
105
+ "title" => pr.title,
106
+ "body" => pr.body,
107
+ "author" => {"login" => pr.author},
108
+ "state" => pr.state.to_s.upcase,
109
+ "isDraft" => pr.draft,
110
+ "baseRefName" => pr.base_ref,
111
+ "baseRefOid" => snapshot.base_sha,
112
+ "headRefName" => pr.head_ref,
113
+ "headRefOid" => pr.head_sha,
114
+ "head_repository_url" => pr.head_repository_url,
115
+ "url" => pr.url,
116
+ "changedFiles" => snapshot.files.length,
117
+ "files" => snapshot.files.map { |path| {"path" => path} },
118
+ "checks" => snapshot.checks.map { |check| check.to_h.transform_keys(&:to_s) }
119
+ }
120
+ end
121
+
122
+ def comments_for(snapshot)
123
+ pr = snapshot.pull_request
124
+ comments = snapshot.review_evidence.comments
125
+ issue_comments = comments.reject(&:path).map do |comment|
126
+ {type: "issue_comment", id: comment.id, author: comment.author,
127
+ body: comment.body, url: comment.url}
128
+ end
129
+ inline = comments.select(&:path)
130
+ # Comments sharing a provider thread id belong to one thread; a
131
+ # nil thread id stays an honest per-comment entry.
132
+ threads = inline.select(&:thread_id).group_by(&:thread_id).map do |id, members|
133
+ first = members.first
134
+ {id: id, path: first.path, line: first.line, is_resolved: first.resolved,
135
+ comments: members.map { |comment|
136
+ {id: comment.id, author: comment.author, body: comment.body, url: comment.url}
137
+ }}
138
+ end
139
+ threads += inline.reject(&:thread_id).map do |comment|
140
+ {id: nil, path: comment.path, line: comment.line, is_resolved: comment.resolved,
141
+ comments: [{id: comment.id, author: comment.author, body: comment.body, url: comment.url}]}
142
+ end
143
+ reviews = snapshot.review_evidence.reviews.map do |review|
144
+ {id: review.id, author: review.author, body: review.body,
145
+ state: review.state.to_s.upcase, url: review.url}
146
+ end
147
+ {
148
+ success: true, comments: issue_comments, review_threads: threads,
149
+ reviews: reviews, pr_number: pr.number, pr_title: pr.title,
150
+ pr_author: pr.author, server_name: pr.server_name,
151
+ repository_url: pr.base_repository_url, head_sha: pr.head_sha
152
+ }
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end