ace-review 0.57.0 → 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: ad21edc3f01275162f0b17aaa9033413abdca90a54a89b7240305b350087ac1a
4
- data.tar.gz: 8a516e358225b6351f05b631f0866bb51c23c7cbfdf69dfd6c19e59a764c7d29
3
+ metadata.gz: fda3bb688af1e2d4c71e6f37dc20d25c4f756703c3a2910827629975fd0965e1
4
+ data.tar.gz: a479d205b501a785063680654466e0eda84e5d00397cc74831dbffc24d106552
5
5
  SHA512:
6
- metadata.gz: a22b32abda5cdfdef0c9c01a0cdeb9668aa60a98884ba93c48ea96d3c1f8ec1455fe08cbff16944afc661f2d4760a1aa644ea944b874e53ef6b5c033636d28d4
7
- data.tar.gz: 3e65df04186e2748b7ab716555157ecb2c5e4153cb78928a8a15823717ee750f5bb346c9328f8f401f09f7570c02cf81cdade75f08159bc2aaee0afd427019c4
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,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.58.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - Provider-neutral PR reviews and comment handling for GitHub, default Forgejo, and named Forgejo servers with exact-head evidence.
14
+
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.
18
+
19
+
10
20
  ## [0.57.0] - 2026-10-01
11
21
 
12
22
  ### Fixed
@@ -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
@@ -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
@@ -394,7 +394,7 @@ module Ace
394
394
 
395
395
  def current_revisions(record)
396
396
  if @live_git && record["subject"]["pr"]
397
- metadata = fetch_pr_metadata(record["subject"]["pr"])
397
+ metadata = fetch_pr_metadata(record["subject"])
398
398
  raise Contract::Invalid, metadata[:error] unless metadata[:success]
399
399
  value = metadata[:metadata]
400
400
  validate_repository(record, value["url"])
@@ -409,11 +409,14 @@ module Ace
409
409
  [head, base]
410
410
  end
411
411
 
412
- def fetch_pr_metadata(pr)
413
- Molecules::GhPrFetcher.fetch_metadata(pr)
414
- rescue Errors::GhCliNotInstalledError, Errors::GhAuthenticationError,
415
- Ace::Git::ProviderCliMissingError, Ace::Git::ProviderAuthenticationError => e
416
- raise Contract::Invalid, "PR source unavailable: #{e.message}"
412
+ def fetch_pr_metadata(subject)
413
+ server = configured_pr_server(subject["repository"])
414
+ # Only head/base identity is consumed here; skip the full diff/
415
+ # comment/check inventory.
416
+ result = Molecules::PrProvider.new(server_name: server.name).fetch_metadata(subject["pr"])
417
+ result[:success] ? result : result.merge(error: "PR source unavailable: #{result[:error]}")
418
+ rescue Ace::Git::Error, ArgumentError => e
419
+ {success: false, error: "PR source unavailable: #{e.message}"}
417
420
  end
418
421
 
419
422
  def clean_candidate?
@@ -426,7 +429,11 @@ module Ace
426
429
  def pr_url_for(subject)
427
430
  return nil unless subject["pr"]
428
431
  parsed = Ace::Git::Atoms::PrIdentifier.parse(subject["pr"])
429
- "#{subject['repository'].delete_suffix('/')}/pull/#{parsed.number}"
432
+ server = configured_pr_server(subject["repository"])
433
+ suffix = (server.provider == :forgejo) ? "pulls" : "pull"
434
+ # Clone-form server URLs (trailing .git) are not valid web bases.
435
+ web_base = server.url.delete_suffix("/").sub(/\.git\z/i, "")
436
+ "#{web_base}/#{suffix}/#{parsed.number}"
430
437
  end
431
438
 
432
439
  def git_revision(ref, record = nil)
@@ -443,13 +450,13 @@ module Ace
443
450
  def validate_repository(record, pr_url)
444
451
  subject = record["subject"]
445
452
  if subject["pr"]
446
- unless subject["repository"].start_with?("https://github.com/")
447
- raise Contract::Invalid, "unsupported PR source; no configured adapter for #{subject['repository']}"
448
- end
453
+ server = configured_pr_server(subject["repository"])
449
454
  parsed = Ace::Git::Atoms::PrIdentifier.parse(subject["pr"])
450
- expected = "#{subject['repository'].delete_suffix('/')}/pull/#{parsed.number}"
451
- unless subject["repository"].delete_prefix("https://github.com/").delete_suffix("/") == parsed.repo &&
452
- pr_url.is_a?(String) && pr_url.downcase == expected
455
+ selected_repo = Ace::Git::Atoms::ServerUrl.normalize(server.url).split("/", 2)[1]
456
+ supplied = Ace::Git::Atoms::PrReference.parse(pr_url)
457
+ unless parsed.repo&.casecmp?(selected_repo) && supplied &&
458
+ supplied.number == parsed.number.to_i && supplied.repository_url ==
459
+ Ace::Git::Atoms::ServerUrl.normalize(server.url)
453
460
  raise Contract::Invalid, "PR session belongs to a different repository or PR"
454
461
  end
455
462
  else
@@ -461,6 +468,15 @@ module Ace
461
468
  end
462
469
  end
463
470
 
471
+ def configured_pr_server(repository)
472
+ matches = Ace::Git::ServerRegistry.matching_servers(repository)
473
+ unless matches.one?
474
+ raise Contract::Invalid,
475
+ "PR repository #{repository} must match exactly one configured forge server"
476
+ end
477
+ matches.first
478
+ end
479
+
464
480
  def allocate_id
465
481
  value = Ace::B36ts.encode(Time.now)
466
482
  value = (value.to_i(36) + 1).to_s(36).rjust(6, "0") while store.registered_id?(value)